From ff60503ce6230aef34d10daf6ecebbf9f371f11a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 12:41:47 +0000 Subject: [PATCH 01/26] :bug: Fix removeChild crash on all portal components The previous fix (80b64c440c) only addressed portal-on-document* but there were 6 additional components that portaled directly to document.body, causing the same race condition when React attempted to remove a node that had already been detached during concurrent state updates (e.g. navigating away while a context menu is open). Apply the dedicated-container pattern consistently to all portal sites: modal, context menus, combobox dropdown, theme selector, and tooltip. Each component now creates a dedicated
container appended to body on mount and removed on cleanup, giving React an exclusive containerInfo for each portal instance. Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/ds/tooltip/tooltip.cljs | 9 ++++++++- frontend/src/app/main/ui/modal.cljs | 13 +++++++++---- .../workspace/tokens/management/context_menu.cljs | 10 ++++++++-- .../tokens/management/forms/controls/combobox.cljs | 9 ++++++++- .../tokens/management/node_context_menu.cljs | 8 +++++++- .../ui/workspace/tokens/themes/theme_selector.cljs | 11 +++++++++-- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 968361f865..f1598364d4 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -159,6 +159,8 @@ tooltip-ref (mf/use-ref nil) + container (mf/use-memo #(dom/create-element "div")) + id (d/nilv id internal-id) @@ -244,6 +246,11 @@ content aria-label)})] + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + (mf/use-effect (mf/deps tooltip-id) (fn [] @@ -295,4 +302,4 @@ [:div {:class (stl/css :tooltip-content)} content] [:div {:class (stl/css :tooltip-arrow) :id "tooltip-arrow"}]]]) - (.-body js/document)))])) + container))])) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 9d260de69e..ce09cc71da 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -83,7 +83,12 @@ (mf/defc modal-container* {::mf/props :obj} [] - (when-let [modal (mf/deref ref:modal)] - (mf/portal - (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) - (dom/get-body)))) + (let [container (mf/use-memo #(dom/create-element "div"))] + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + (when-let [modal (mf/deref ref:modal)] + (mf/portal + (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index eb43f4a23b..22e1915e64 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -515,7 +515,13 @@ dropdown-direction (deref dropdown-direction*) dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) - left (+ (get-in mdata [:position :x]) 5)] + left (+ (get-in mdata [:position :x]) 5) + container (mf/use-memo #(dom/create-element "div"))] + + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) (mf/use-effect (mf/deps is-open?) @@ -554,4 +560,4 @@ :on-context-menu prevent-default} (when mdata [:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]]) - (dom/get-body))))) + container)))) 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 d53b8d0d60..2c5b4e25cd 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 @@ -92,6 +92,8 @@ icon-button-ref (mf/use-ref nil) ref (or ref internal-ref) + container (mf/use-memo #(dom/create-element "div")) + raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) filtered-tokens-by-type @@ -267,6 +269,11 @@ (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + (mf/with-effect [is-open* ref wrapper-ref] (when is-open (let [handler (fn [event] @@ -305,4 +312,4 @@ :empty-to-end empty-to-end :wrapper-ref dropdown-ref :ref set-option-ref}]) - (dom/get-body))))])) \ No newline at end of file + container)))])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index 4e272f7bdd..c9b6947316 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -35,6 +35,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) + container (mf/use-memo #(dom/create-element "div")) delete-node (mf/use-fn (mf/deps mdata) @@ -44,6 +45,11 @@ (when node (on-delete-node node type)))))] + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + (mf/with-effect [is-open?] (when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?)) (reset! dropdown-direction* "down") @@ -80,4 +86,4 @@ :type "button" :on-click delete-node} (tr "labels.delete")]]])]]) - (dom/get-body))))) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 3d799e0b59..35b775c9c6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -111,7 +111,14 @@ (let [rect (dom/get-bounding-rect node)] (swap! state* assoc :is-open? true - :rect rect))))))] + :rect rect)))))) + + container (mf/use-memo #(dom/create-element "div"))] + + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) [:div {:on-click on-open-dropdown :disabled (not can-edit?) @@ -140,4 +147,4 @@ [:& theme-options {:active-theme-paths active-theme-paths :themes themes :on-close on-close-dropdown}]]]) - (dom/get-body)))])) + container))])) From 2905905a9f95e6fd8e6e17385c3eb5d933628ee3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 13:47:33 +0000 Subject: [PATCH 02/26] :recycle: Extract use-portal-container hook to reduce duplication The dedicated-container portal pattern was repeated across 7 components. Extract it into a reusable use-portal-container hook under app.main.ui.hooks. Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/components/portal.cljs | 8 ++------ frontend/src/app/main/ui/ds/tooltip/tooltip.cljs | 8 ++------ frontend/src/app/main/ui/hooks.cljs | 12 ++++++++++++ frontend/src/app/main/ui/modal.cljs | 7 ++----- .../ui/workspace/tokens/management/context_menu.cljs | 8 ++------ .../tokens/management/forms/controls/combobox.cljs | 8 ++------ .../tokens/management/node_context_menu.cljs | 8 ++------ .../ui/workspace/tokens/themes/theme_selector.cljs | 8 ++------ 8 files changed, 26 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index ff9f3558d4..381db4b66c 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -6,16 +6,12 @@ (ns app.main.ui.components.portal (:require - [app.util.dom :as dom] + [app.main.ui.hooks :as hooks] [rumext.v2 :as mf])) (mf/defc portal-on-document* [{:keys [children]}] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + (let [container (hooks/use-portal-container)] (mf/portal (mf/html [:* children]) container))) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index f1598364d4..74764a1a4c 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -9,6 +9,7 @@ [app.main.style :as stl]) (:require [app.common.data :as d] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.timers :as ts] @@ -159,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (mf/use-memo #(dom/create-element "div")) + container (hooks/use-portal-container) id (d/nilv id internal-id) @@ -246,11 +247,6 @@ content aria-label)})] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - (mf/use-effect (mf/deps tooltip-id) (fn [] diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index b4ad8fe616..42560cd8fe 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,6 +380,18 @@ state)) +(defn use-portal-container + "Creates a dedicated div container for React portals. The container + is appended to document.body on mount and removed on cleanup, preventing + removeChild race conditions when multiple portals target the same body." + [] + (let [container (mf/use-memo #(dom/create-element "div"))] + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + container)) + (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) ([itemsize] diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index ce09cc71da..5df1cc3daa 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as k] [goog.events :as events] @@ -83,11 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + (let [container (hooks/use-portal-container)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index 22e1915e64..0dd980f73f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -20,6 +20,7 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.timers :as timers] @@ -516,12 +517,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (mf/use-memo #(dom/create-element "div"))] - - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + container (hooks/use-portal-container)] (mf/use-effect (mf/deps is-open?) 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 2c5b4e25cd..3e6dec43b1 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 @@ -19,6 +19,7 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.forms :as fc] + [app.main.ui.hooks :as hooks] [app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation :refer [use-navigation]] [app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown :refer [use-floating-dropdown]] [app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp] @@ -92,7 +93,7 @@ icon-button-ref (mf/use-ref nil) ref (or ref internal-ref) - container (mf/use-memo #(dom/create-element "div")) + container (hooks/use-portal-container) raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) @@ -269,11 +270,6 @@ (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - (mf/with-effect [is-open* ref wrapper-ref] (when is-open (let [handler (fn [event] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index c9b6947316..d37e628d02 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -6,6 +6,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [okulary.core :as l] @@ -35,7 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (mf/use-memo #(dom/create-element "div")) + container (hooks/use-portal-container) delete-node (mf/use-fn (mf/deps mdata) @@ -45,11 +46,6 @@ (when node (on-delete-node node type)))))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - (mf/with-effect [is-open?] (when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?)) (reset! dropdown-direction* "down") diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 35b775c9c6..a8687c9719 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -17,6 +17,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str] @@ -113,12 +114,7 @@ :is-open? true :rect rect)))))) - container (mf/use-memo #(dom/create-element "div"))] - - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + container (hooks/use-portal-container)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) From 84ac86af5b04589c77adf8ac79e20bfca349fd38 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 23 Mar 2026 12:38:44 +0100 Subject: [PATCH 03/26] :bug: Fix whitespace parsing and word capitalization --- render-wasm/src/shapes/text.rs | 169 ++++++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 32 deletions(-) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 9ef4da3d07..96adb52b28 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1002,6 +1002,49 @@ impl Paragraph { } } +/// Capitalize the first letter of each word, preserving all original whitespace. +/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after +/// any non-letter character (whitespace, punctuation, digits, symbols). +fn capitalize_words(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut capitalize_next = true; + for c in text.chars() { + if c.is_alphabetic() { + if capitalize_next { + result.extend(c.to_uppercase()); + } else { + result.push(c); + } + capitalize_next = false; + } else { + result.push(c); + capitalize_next = true; + } + } + result +} + +/// Filter control characters below U+0020, preserving line breaks. +/// Browser-dependent: Firefox drops them, others replace with space. +fn process_ignored_chars(text: &str, browser: u8) -> String { + text.chars() + .filter_map(|c| { + if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { + return Some(c); + } + if c < '\u{0020}' { + if browser == Browser::Firefox as u8 { + None + } else { + Some(' ') + } + } else { + Some(c) + } + }) + .collect() +} + #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { pub text: String, @@ -1136,43 +1179,13 @@ impl TextSpan { format!("{}", self.font_family) } - fn process_ignored_chars(text: &str, browser: u8) -> String { - text.chars() - .filter_map(|c| { - // Preserve line breaks: \n (U+000A), \r (U+000D), and Unicode separators - if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { - return Some(c); - } - if c < '\u{0020}' { - if browser == Browser::Firefox as u8 { - None - } else { - Some(' ') - } - } else { - Some(c) - } - }) - .collect() - } - pub fn apply_text_transform(&self) -> String { let browser = crate::with_state!(state, { state.current_browser }); - let text = Self::process_ignored_chars(&self.text, browser); + let text = process_ignored_chars(&self.text, browser); match self.text_transform { Some(TextTransform::Uppercase) => text.to_uppercase(), Some(TextTransform::Lowercase) => text.to_lowercase(), - Some(TextTransform::Capitalize) => text - .split_whitespace() - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } - }) - .collect::>() - .join(" "), + Some(TextTransform::Capitalize) => capitalize_words(&text), None => text, } } @@ -1464,3 +1477,95 @@ pub fn calculate_position_data( layout_info.position_data } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capitalize_basic_words() { + assert_eq!(capitalize_words("hello world"), "Hello World"); + } + + #[test] + fn capitalize_preserves_leading_whitespace() { + assert_eq!(capitalize_words(" hello"), " Hello"); + } + + #[test] + fn capitalize_preserves_trailing_whitespace() { + assert_eq!(capitalize_words("hello "), "Hello "); + } + + #[test] + fn capitalize_preserves_multiple_spaces() { + assert_eq!(capitalize_words("hello world"), "Hello World"); + } + + #[test] + fn capitalize_whitespace_only() { + assert_eq!(capitalize_words(" "), " "); + assert_eq!(capitalize_words(" "), " "); + } + + #[test] + fn capitalize_empty_string() { + assert_eq!(capitalize_words(""), ""); + } + + #[test] + fn capitalize_single_char() { + assert_eq!(capitalize_words("a"), "A"); + } + + #[test] + fn capitalize_already_uppercase() { + assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD"); + } + + #[test] + fn capitalize_preserves_tabs_and_newlines() { + assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld"); + assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld"); + } + + #[test] + fn capitalize_after_punctuation() { + assert_eq!(capitalize_words("(readonly)"), "(Readonly)"); + assert_eq!(capitalize_words("hello-world"), "Hello-World"); + assert_eq!(capitalize_words("one/two/three"), "One/Two/Three"); + } + + #[test] + fn capitalize_after_digits() { + assert_eq!(capitalize_words("item1name"), "Item1Name"); + } + + #[test] + fn process_ignored_chars_preserves_spaces() { + assert_eq!(process_ignored_chars("hello world", 0), "hello world"); + } + + #[test] + fn process_ignored_chars_preserves_line_breaks() { + assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld"); + assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); + } + + #[test] + fn process_ignored_chars_replaces_control_chars_chrome() { + // U+0001 (SOH) should become space in non-Firefox + assert_eq!( + process_ignored_chars("a\x01b", Browser::Chrome as u8), + "a b" + ); + } + + #[test] + fn process_ignored_chars_removes_control_chars_firefox() { + assert_eq!( + process_ignored_chars("a\x01b", Browser::Firefox as u8), + "ab" + ); + } +} From 43be99492011d63ffdac804cd10de4892118c860 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 23 Mar 2026 11:00:54 +0100 Subject: [PATCH 04/26] :bug: Fix problem with multiple export --- frontend/src/app/main/render.cljs | 37 ++++++++++++++++--------- frontend/src/app/render.cljs | 46 +++++++++++++++++++------------ 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 29f7571625..6e40dbcbda 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -485,21 +485,32 @@ [:& shape-wrapper {:shape object}]]]])) (defn render-to-canvas - [objects canvas bounds scale object-id] - (try - (when (wasm.api/init-canvas-context canvas) - (wasm.api/initialize-viewport - objects scale bounds "#000000" 0 - (fn [] - (wasm.api/render-sync-shape object-id) - (dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id))))) - (catch :default e - (js/console.error "Error initializing canvas context:" e) - false))) + [objects canvas bounds scale object-id on-render] + (let [width (.-width canvas) + height (.-height canvas) + os-canvas (js/OffscreenCanvas. width height)] + (try + (when (wasm.api/init-canvas-context os-canvas) + (wasm.api/initialize-viewport + objects scale bounds "#000000" 0 + (fn [] + (wasm.api/render-sync-shape object-id) + (ts/raf + (fn [] + (let [bitmap (.transferToImageBitmap os-canvas) + ctx2d (.getContext canvas "2d")] + (.clearRect ctx2d 0 0 width height) + (.drawImage ctx2d bitmap 0 0) + (dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id)) + (wasm.api/clear-canvas) + (on-render))))))) + (catch :default e + (js/console.error "Error initializing canvas context:" e) + false)))) (mf/defc object-wasm {::mf/wrap [mf/memo]} - [{:keys [objects object-id skip-children scale] :as props}] + [{:keys [objects object-id skip-children scale on-render] :as props}] (let [object (get objects object-id) object (cond-> object (:hide-fill-on-export object) @@ -521,7 +532,7 @@ (p/fmap (fn [ready?] (when ready? - (render-to-canvas objects canvas bounds scale object-id)))))))) + (render-to-canvas objects canvas bounds scale object-id on-render)))))))) [:canvas {:ref canvas-ref :width (* scale width) diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index e134d5d95b..a7fef90ca9 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -7,6 +7,7 @@ (ns app.render "The main entry point for UI part needed by the exporter." (:require + [app.common.data :as d] [app.common.geom.shapes.bounds :as gsb] [app.common.logging :as log] [app.common.math :as mth] @@ -95,25 +96,34 @@ (mf/defc objects-svg {::mf/wrap-props false} [{:keys [object-ids embed skip-children wasm scale]}] - (when-let [objects (mf/deref ref:objects)] - (for [object-id object-ids] - (let [objects (render/adapt-objects-for-shape objects object-id)] - (if wasm - [:& render/object-wasm - {:objects objects - :key (str object-id) - :object-id object-id - :embed embed - :scale scale - :skip-children skip-children}] + (let [limit + (mf/use-state (if wasm (min 1 (count object-ids)) (count object-ids))) - [:& (mf/provider ctx/is-render?) {:value true} - [:& render/object-svg - {:objects objects - :key (str object-id) - :object-id object-id - :embed embed - :skip-children skip-children}]]))))) + cb-fn + (mf/use-fn + (fn [] + (swap! limit #(min (count object-ids) (inc %)))))] + (when-let [objects (mf/deref ref:objects)] + ;;Limit + (for [object-id (take @limit object-ids)] + (let [objects (render/adapt-objects-for-shape objects object-id)] + (if wasm + [:& render/object-wasm + {:objects objects + :key (str object-id) + :object-id object-id + :embed embed + :scale (d/parse-integer scale) + :skip-children skip-children + :on-render cb-fn}] + + [:& (mf/provider ctx/is-render?) {:value true} + [:& render/object-svg + {:objects objects + :key (str object-id) + :object-id object-id + :embed embed + :skip-children skip-children}]])))))) (defn- fetch-objects-bundle [& {:keys [file-id page-id share-id object-id] :as options}] From b484415a9ff1b107151cdb81246da14f9598609e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 20:03:14 +0100 Subject: [PATCH 05/26] :bug: Fix generic error shown on clipboard permission denial (#8666) When the browser denies clipboard read permission (NotAllowedError), the unhandled exception handler was showing a generic 'Something wrong has happened' toast. This change adds proper error handling for clipboard permission errors in paste operations and shows a user-friendly warning message instead. Changes: - Add error handling in paste-from-clipboard for NotAllowedError - Improve error handling in paste-selected-props to detect permission errors - Mark clipboard NotAllowedError as ignorable in the uncaught error handler to prevent duplicate generic error toasts - Add translation key for clipboard permission denied message Signed-off-by: Andrey Antukh --- .../app/main/data/workspace/clipboard.cljs | 38 ++++++++++++++++--- frontend/translations/en.po | 4 ++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 4bd8190895..e32de485e5 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -294,6 +294,22 @@ (def default-paste-from-blob (create-paste-from-blob false)) +(defn- clipboard-permission-error? + "Check if the given error is a clipboard permission error + (NotAllowedError DOMException)." + [cause] + (and (instance? js/DOMException cause) + (= (.-name cause) "NotAllowedError"))) + +(defn- on-clipboard-permission-error + [cause] + (if (clipboard-permission-error? cause) + (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") + :type :toast + :level :warning + :timeout 5000})) + (rx/throw cause))) + (defn paste-from-clipboard "Perform a `paste` operation using the Clipboard API." [] @@ -302,7 +318,8 @@ (watch [_ _ _] (->> (clipboard/from-navigator default-options) (rx/mapcat default-paste-from-blob) - (rx/take 1))))) + (rx/take 1) + (rx/catch on-clipboard-permission-error))))) (defn paste-from-event "Perform a `paste` operation from user emmited event." @@ -482,11 +499,20 @@ (-> entry t/decode-str paste-transit-props)) (on-error [cause] - (let [data (ex-data cause)] - (if (:not-implemented data) - (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) - (js/console.error "Clipboard error:" cause)) - (rx/empty)))] + (cond + (clipboard-permission-error? cause) + (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") + :type :toast + :level :warning + :timeout 5000})) + + (:not-implemented (ex-data cause)) + (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) + + :else + (do + (js/console.error "Clipboard error:" cause) + (rx/empty))))] (->> (clipboard/from-navigator default-options) (rx/mapcat #(.text %)) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 495fd1b9c2..a39cfaf85c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1375,6 +1375,10 @@ msgstr "Character limit exceeded" msgid "errors.clipboard-not-implemented" msgstr "Your browser cannot do this operation" +#: src/app/main/data/workspace/clipboard.cljs +msgid "errors.clipboard-permission-denied" +msgstr "Clipboard access denied. Please allow clipboard permissions in your browser to paste content" + #: src/app/main/errors.cljs:235 msgid "errors.comment-error" msgstr "There was an error with the comment" From 577f00dd24aa762171ab47263930114f1fbdbba9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 19:19:27 +0000 Subject: [PATCH 06/26] :bug: Fix error when get-parent-with-data encounters non-Element nodes The get-parent-with-data function traverses the DOM using parentElement to find an ancestor with a specific data-* attribute. When the current node is a non-Element DOM node (e.g. Document node reached from event handlers on window), accessing .-dataset returns undefined, causing obj/in? to throw "right-hand side of 'in' should be an object". This adds a nodeType check to skip non-Element nodes during traversal and continue up the parent chain. Signed-off-by: Andrey Antukh --- frontend/src/app/util/dom.cljs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index ffa2b8f361..4e068cc5d3 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -170,8 +170,17 @@ [^js node name] (let [name (str/camel name)] (loop [current node] - (if (or (nil? current) (obj/in? (.-dataset current) name)) + (cond + (nil? current) + nil + + (not= (.-nodeType current) js/Node.ELEMENT_NODE) + (recur (.-parentElement current)) + + (obj/in? (.-dataset current) name) current + + :else (recur (.-parentElement current)))))) (defn get-parent-with-selector From d051a3ba4572c9aea3c43b8d2062ce4b4c50d679 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 00:18:44 +0000 Subject: [PATCH 07/26] :bug: Ensure path content is always PathData when saving The save-path-content function only converted content to PathData when there was a trailing :move-to command. When there was no trailing :move-to, the content from get-path was stored as-is, which could be a plain vector if the shape was already a :path type with non-PathData content. This caused segment/get-points to fail with 'can't access property "get", cache is undefined' when the with-cache macro tried to access the cache field on a non-PathData object. The fix ensures content is always converted to PathData via path/content before being stored in the state. Signed-off-by: Andrey Antukh --- frontend/src/app/main/data/workspace/path/changes.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/path/changes.cljs b/frontend/src/app/main/data/workspace/path/changes.cljs index c2bc293da3..8eb73b3663 100644 --- a/frontend/src/app/main/data/workspace/path/changes.cljs +++ b/frontend/src/app/main/data/workspace/path/changes.cljs @@ -69,7 +69,7 @@ content (if (and (not preserve-move-to) (= (-> content last :command) :move-to)) (path/content (take (dec (count content)) content)) - content)] + (path/content content))] (st/set-content state content))) ptk/WatchEvent From 13b5c96a429070b959d5cd6e0082112ce6724efc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 09:19:58 +0100 Subject: [PATCH 08/26] :paperclip: Update changelog --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 6d52b2b01f..d5f46c6c3b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ # CHANGELOG -## 2.14.0 (Unreleased) +## 2.14.0 ### :boom: Breaking changes & Deprecations From c920c092cc0e77023c62a87dd79d83209addf5db Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Tue, 24 Mar 2026 11:01:08 +0100 Subject: [PATCH 09/26] :bug: Fix text selection misalignment --- render-wasm/src/render/text_editor.rs | 13 ++----------- render-wasm/src/shapes.rs | 1 + render-wasm/src/wasm/text_editor.rs | 12 +++--------- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index beb1c1384b..75178d146b 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,14 +1,9 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect}; +use skia_safe::{BlendMode, Canvas, Paint, Rect}; -pub fn render_overlay( - canvas: &Canvas, - editor_state: &TextEditorState, - shape: &Shape, - transform: &Matrix, -) { +pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &Shape) { if !editor_state.is_active { return; } @@ -18,16 +13,12 @@ pub fn render_overlay( }; canvas.save(); - canvas.concat(transform); - if editor_state.selection.is_selection() { render_selection(canvas, editor_state, text_content, shape); } - if editor_state.cursor_visible { render_cursor(canvas, editor_state, text_content, shape); } - canvas.restore(); } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index dedf9ab770..13115a9946 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1212,6 +1212,7 @@ impl Shape { matrix } + #[allow(dead_code)] pub fn get_concatenated_matrix(&self, shapes: ShapesPoolRef) -> Matrix { let mut matrix = Matrix::new_identity(); let mut current_id = self.id; diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index eeac88ce43..1c6de52fe8 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -2,6 +2,8 @@ use macros::{wasm_error, ToJs}; use crate::math::{Matrix, Point, Rect}; use crate::mem; +use crate::render::text_editor as text_editor_render; +use crate::render::SurfaceId; use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::TextSelection; use crate::utils::uuid_from_u32_quartet; @@ -841,21 +843,13 @@ pub extern "C" fn text_editor_render_overlay() { return; }; - let transform = shape.get_concatenated_matrix(&state.shapes); - - use crate::render::text_editor as te_render; - use crate::render::SurfaceId; - let canvas = state.render_state.surfaces.canvas(SurfaceId::Target); - canvas.save(); let viewbox = state.render_state.viewbox; let zoom = viewbox.zoom * state.render_state.options.dpr(); canvas.scale((zoom, zoom)); canvas.translate((-viewbox.area.left, -viewbox.area.top)); - - te_render::render_overlay(canvas, &state.text_editor_state, shape, &transform); - + text_editor_render::render_overlay(canvas, &state.text_editor_state, shape); canvas.restore(); state.render_state.flush_and_submit(); }); From 55d763736f8bc71f200f8f49f40b0fe38f88293c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 24 Mar 2026 11:09:10 +0100 Subject: [PATCH 10/26] :bug: Fix backspace breaks ctrl+z --- .../src/app/main/data/workspace/texts.cljs | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 0d11245453..af03561722 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -927,23 +927,25 @@ final-shape-fn (fn [shape] (merge shape final-geom))] (-> base (pcb/with-objects objects-with-old) - (pcb/update-shapes [id] final-shape-fn {:attrs attrs}) - (pcb/set-stack-undo? true)))) + (pcb/update-shapes [id] final-shape-fn {:attrs attrs})))) (defn- build-finalize-commit-changes "Builds the commit changes for text finalization (content + geometry undo). - For auto-width text, include geometry so undo restores e.g. width." - [it state id {:keys [new-shape? content-has-text? content original-content undo-group]}] + For auto-width text, include geometry so undo restores e.g. width. + Includes :name when update-name? so we can skip save-undo on the preceding + update-shapes for finalize without losing name undo." + [it state id {:keys [new-shape? content-has-text? content original-content + update-name? name]}] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape* (get objects id) base (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) (pcb/set-text-content id content original-content) + (cond-> (and update-name? (some? name) (not= (:name shape*) name)) + (pcb/update-shapes [id] (fn [s] (assoc s :name name)) {:attrs [:name]})) (cond-> new-shape? (-> (pcb/set-undo-group id) - (pcb/set-stack-undo? true))) - (cond-> (and (not new-shape?) (some? undo-group)) - (-> (pcb/set-undo-group undo-group) (pcb/set-stack-undo? true)))) final-geom (select-keys shape* [:selrect :points :width :height]) geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height]) @@ -987,7 +989,13 @@ ;; New shapes: single undo on finalize only (no per-keystroke undo) effective-save-undo? (if new-shape? finalize? save-undo?) effective-stack-undo? (and new-shape? finalize?) - finalize-undo-group (when (and finalize? (not new-shape?)) (uuid/next))] + ;; No save-undo on first update when finalizing: either build-finalize + ;; holds undo (non-new), or we delete empty text and only delete-shapes + ;; should record undo. + finalize-save-undo-first? + (if (and finalize? (or (not new-shape?) (not content-has-text?))) + false + effective-save-undo?)] (rx/concat (rx/of @@ -1006,16 +1014,17 @@ (dissoc :prev-content)) (cond-> (and (not new-shape?) prev-content-has-text? - (not content-has-text?)) + (not content-has-text?) + (not finalize?)) (assoc :prev-content prev-content)) (cond-> (and update-name? (some? name)) (assoc :name name)) (cond-> (some? new-size) (gsh/transform-shape (ctm/change-size shape (:width new-size) (:height new-size)))))) - {:save-undo? effective-save-undo? + {:save-undo? finalize-save-undo-first? :stack-undo? effective-stack-undo? - :undo-group (or finalize-undo-group (when new-shape? id))}) + :undo-group (when new-shape? id)}) ;; When we don't update the shape (no new-size), still update WASM display (when-not (some? new-size) @@ -1024,28 +1033,35 @@ (when finalize? (rx/concat - (when (and (not content-has-text?) (some? id)) - (rx/of - (when has-prev-content? - (dwsh/update-shapes - [id] - (fn [shape] (assoc shape :content (:prev-content shape))) - {:save-undo? false})) - (dws/deselect-shape id) - (dwsh/delete-shapes #{id}))) - (rx/of - (dch/commit-changes - (build-finalize-commit-changes it state id - {:new-shape? new-shape? - :content-has-text? content-has-text? - :content content - :original-content original-content - :undo-group finalize-undo-group})) - (dwt/finish-transform) - (fn [state] - (-> state - (update :workspace-new-text-shapes disj id) - (update :workspace-text-session-geom (fnil dissoc {}) id)))))))) + (if (and (not content-has-text?) (some? id)) + (rx/concat + (if (and (some? original-content) (v2-content-has-text? original-content)) + (rx/of + (dwsh/update-shapes + [id] + (fn [s] (-> s (assoc :content original-content) (dissoc :prev-content))) + {:save-undo? false})) + (rx/empty)) + (rx/of (dws/deselect-shape id) + (dwsh/delete-shapes #{id}))) + (rx/empty)) + (rx/concat + (if content-has-text? + (rx/of + (dch/commit-changes + (build-finalize-commit-changes it state id + {:new-shape? new-shape? + :content-has-text? content-has-text? + :content content + :original-content original-content + :update-name? update-name? + :name name}))) + (rx/empty)) + (rx/of (dwt/finish-transform) + (fn [state] + (-> state + (update :workspace-new-text-shapes disj id) + (update :workspace-text-session-geom (fnil dissoc {}) id))))))))) (let [modifiers (get-in state [:workspace-text-modifier id]) new-shape? (contains? (:workspace-new-text-shapes state) id)] From dd6a3c291aee91c9ee7dc57c4ad1beffeb399e1d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 24 Mar 2026 11:22:52 +0100 Subject: [PATCH 11/26] :bug: Fix tooltip shown on tab change (#8719) --- .../src/app/main/ui/ds/tooltip/tooltip.cljs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 968361f865..4c35d37303 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -198,6 +198,14 @@ (reset! active-tooltip {:id tooltip-id :trigger trigger-el}) (reset! visible* true))))))) + on-show-focus + (mf/use-fn + (mf/deps on-show) + (fn [event] + (let [related (dom/get-related-target event)] + (when (some? related) + (on-show event))))) + on-hide (mf/use-fn (mf/deps tooltip-id) @@ -234,7 +242,7 @@ (mf/spread-props props {:on-mouse-enter on-show :on-mouse-leave on-hide - :on-focus on-show + :on-focus on-show-focus :on-blur on-hide :ref internal-trigger-ref :on-key-down handle-key-down @@ -244,17 +252,6 @@ content aria-label)})] - (mf/use-effect - (mf/deps tooltip-id) - (fn [] - (let [handle-visibility-change - (fn [] - (when (.-hidden js/document) - (on-hide)))] - (js/document.addEventListener "visibilitychange" handle-visibility-change) - ;; cleanup - #(js/document.removeEventListener "visibilitychange" handle-visibility-change)))) - (mf/use-effect (mf/deps visible placement offset) (fn [] From 937032c7903eb537e071757f0af5453dd166bc41 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 23 Mar 2026 16:27:37 +0100 Subject: [PATCH 12/26] :sparkles: Allow for reconnections to MCP server --- frontend/src/app/main/data/workspace.cljs | 2 +- frontend/src/app/main/data/workspace/mcp.cljs | 66 +++++++++++++++---- .../src/app/main/ui/workspace/main_menu.cljs | 8 +-- mcp/packages/plugin/src/main.ts | 8 ++- mcp/packages/server/src/PluginBridge.ts | 1 + 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index fa2e04fe9d..f1c54caf48 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -377,7 +377,7 @@ (rx/map deref) (rx/mapcat (fn [value] (rx/of (mcp/update-mcp-connection value) - (mcp/disconnect-mcp)))))) + (mcp/user-disconnect-mcp)))))) (when (contains? cf/flags :mcp) (->> mbc/stream diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index a9198750c7..50b70fee43 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -17,9 +17,12 @@ [app.main.store :as st] [app.plugins.register :refer [mcp-plugin-id]] [app.util.i18n :refer [tr]] + [app.util.timers :as ts] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(def retry-interval 10000) + (log/set-level! :info) (def ^:private default-manifest @@ -34,13 +37,53 @@ "comment:read" "comment:write" "content:write" "content:read"}}) +(defonce interval-sub (atom nil)) + (defn finalize-workspace? [event] (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) -(defn disconnect-mcp +(defn set-mcp-active + [value] + (ptk/reify ::set-mcp-active + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:workspace-local :mcp :active] value)))) + +(defn start-reconnect-watcher! [] - (st/emit! (ptk/data-event ::disconnect))) + (st/emit! (set-mcp-active true)) + (when (nil? @interval-sub) + (reset! + interval-sub + (ts/interval + retry-interval + (fn [] + ;; Try to reconnect if active and not connected + (when-not (contains? #{"connecting" "connected"} + (-> @st/state :workspace-local :mcp :connection)) + (.log js/console "Reconnecting to MCP...") + (st/emit! (ptk/data-event ::connect)))))))) + +(defn stop-reconnect-watcher! + [] + (st/emit! (set-mcp-active false)) + (when @interval-sub + (rx/dispose! @interval-sub) + (reset! interval-sub nil))) + +;; This event will arrive when the user selects disconnect on the menu +;; or there is a broadcast message for disconnection +(defn user-disconnect-mcp + [] + (ptk/reify ::remote-disconnect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::disconnect))) + + ptk/EffectEvent + (effect [_ _ _] + (stop-reconnect-watcher!)))) (defn connect-mcp [] @@ -58,12 +101,13 @@ (ptk/reify ::manage-mcp-notification ptk/WatchEvent (watch [_ state _] - (let [mcp-connected? (true? (-> state :workspace-local :mcp :connection)) + (let [mcp-connected? (= "connected" (-> state :workspace-local :mcp :connection)) mcp-enabled? (true? (-> state :profile :props :mcp-enabled)) num-sessions (-> state :workspace-presence count) - multi-session? (> num-sessions 1)] + multi-session? (> num-sessions 1) + mcp-active? (-> state :workspace-local :mcp :active)] (if (and mcp-enabled? multi-session?) - (if mcp-connected? + (if (or mcp-connected? mcp-active?) (rx/of (ntf/hide)) (rx/of (ntf/dialog :content (tr "notifications.mcp.active-in-another-tab") :cancel {:label (tr "labels.dismiss") @@ -76,6 +120,7 @@ ::ev/origin "workspace-notification"}))}))) (rx/of (ntf/hide))))))) +;; This event will arrive when the mcp is enabled in the main menu (defn update-mcp-status [value] (ptk/reify ::update-mcp-status @@ -121,13 +166,10 @@ :getServerUrl #(str cf/mcp-ws-uri) :setMcpStatus (fn [status] - (let [mcp-connection (case status - "connected" true - "disconnected" false - "error" nil - "")] - (st/emit! (update-mcp-connection mcp-connection)) - (log/info :hint "MCP STATUS" :status status))) + (when (= status "connected") + (start-reconnect-watcher!)) + (st/emit! (update-mcp-connection status)) + (log/info :hint "MCP STATUS" :status status)) :on (fn [event cb] diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 223e30ce50..bdaf205ddd 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -750,7 +750,7 @@ workspace-local (mf/deref refs/workspace-local) mcp-enabled? (true? (-> profile :props :mcp-enabled)) - mcp-connected? (true? (-> workspace-local :mcp :connection)) + mcp-connected? (= "connected" (-> workspace-local :mcp :connection)) on-nav-to-integrations (mf/use-fn @@ -769,7 +769,7 @@ (mf/use-fn (fn [] (if mcp-connected? - (st/emit! (mcp/disconnect-mcp) + (st/emit! (mcp/user-disconnect-mcp) (ptk/event ::ev/event {::ev/name "disconnect-mcp-plugin" ::ev/origin "workspace-menu"})) (st/emit! (mcp/connect-mcp) @@ -980,8 +980,8 @@ (when (contains? cf/flags :mcp) (let [mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (-> workspace-local :mcp :connection) - mcp-connected? (true? mcp-connection) - mcp-error? (nil? mcp-connection)] + mcp-connected? (= mcp-connection "connected") + mcp-error? (= mcp-connection "error")] [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 8aad137ec3..45396f421d 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -105,8 +105,12 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { - console.log("Connected to MCP server"); - updateConnectionStatus("connected", "Connected"); + setTimeout(() => { + if (ws) { + console.log("Connected to MCP server"); + updateConnectionStatus("connected", "Connected"); + } + }, 100); }; ws.onmessage = (event) => { diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 10dfb5eeb9..5147d361fd 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -68,6 +68,7 @@ export class PluginBridge { if (this.clientsByToken.has(userToken)) { this.logger.warn("Duplicate connection for given user token; rejecting new connection"); ws.close(1008, "Duplicate connection for given user token; close previous connection first."); + return; } this.clientsByToken.set(userToken, connection); From 5a73003c7fa09ad649e1a881c54ead2b253eb363 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 24 Mar 2026 11:36:16 +0100 Subject: [PATCH 13/26] :bug: Fix fallback fonts and symbols 2 --- frontend/src/app/render_wasm/api/fonts.cljs | 46 ++++++++++++--------- frontend/src/app/render_wasm/api/texts.cljs | 11 +++-- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 8c73f85e2d..f50a7e9a14 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -124,29 +124,35 @@ fallback?) true)) -;; This variable will store the fonts that are currently being fetched -;; so we don't fetch more than once the same font -(def fetching (atom #{})) +;; Tracks fonts currently being fetched: {url -> fallback?} +;; When the same font is requested as both primary and fallback, +;; the fallback flag is upgraded to true so it gets registered +;; in WASM's fallback_fonts set. +(def fetching (atom {})) (defn- fetch-font [font-data font-url emoji? fallback?] - (when-not (contains? @fetching font-url) - (swap! fetching conj font-url) - {:key font-url - :callback - (fn [] - (->> (http/send! {:method :get - :uri font-url - :response-type :buffer}) - (rx/map (fn [{:keys [body]}] - (swap! fetching disj font-url) - (store-font-buffer font-data body emoji? fallback?))) - (rx/catch (fn [cause] - (swap! fetching disj font-url) - (log/error :hint "Could not fetch font" - :font-url font-url - :cause cause) - (rx/empty)))))})) + (if (contains? @fetching font-url) + (do (when fallback? (swap! fetching assoc font-url true)) + nil) + (do + (swap! fetching assoc font-url fallback?) + {:key font-url + :callback + (fn [] + (->> (http/send! {:method :get + :uri font-url + :response-type :buffer}) + (rx/map (fn [{:keys [body]}] + (let [fallback? (get @fetching font-url fallback?)] + (swap! fetching dissoc font-url) + (store-font-buffer font-data body emoji? fallback?)))) + (rx/catch (fn [cause] + (swap! fetching dissoc font-url) + (log/error :hint "Could not fetch font" + :font-url font-url + :cause cause) + (rx/empty)))))}))) (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index 9598cb73bc..8dd8ee1016 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -166,7 +166,7 @@ (h/call wasm/internal-module "_set_shape_text_content"))) (def ^:private emoji-pattern - #"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDEFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]") + #"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]") (def ^:private unicode-ranges {:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]" @@ -215,9 +215,12 @@ :meroitic #"\uD802[\uDD80-\uDD9F]" ;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc. :symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]" - ;; Additional arrows, math, technical, geometric, and symbol blocks - :symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]" - :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"}) + ;; Additional symbol blocks covered by Noto Sans Symbols 2: + ;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.) + ;; 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]"}) (defn contains-emoji? [text] (let [result (re-find emoji-pattern text)] From 5f722d9183da4790ba2d52f62546c0d82b55c356 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 24 Mar 2026 12:27:09 +0100 Subject: [PATCH 14/26] :bug: Fix show red bullet in workspace menu if mcp key is expired (#8727) --- backend/src/app/rpc/commands/profile.clj | 2 +- frontend/src/app/main/data/workspace.cljs | 5 +++- .../src/app/main/ui/workspace/main_menu.cljs | 24 +++++++++++++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 4383ab794f..efe99c4a70 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,7 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] - [:mcp-status {:optional true} ::sm/boolean] + [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index f1c54caf48..64221eecb8 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -340,7 +340,10 @@ (rx/of (ntf/hide) (dcmt/retrieve-comment-threads file-id) (dcmt/fetch-profiles) - (df/fetch-fonts team-id))) + (df/fetch-fonts team-id)) + + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) ;; Once the essential data is fetched, lets proceed to ;; fetch teh file bunldle diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index bdaf205ddd..135ed716e4 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -42,9 +43,13 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] + [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf])) +(def tokens-ref + (l/derived :access-tokens st/state)) + (mf/defc shortcuts* {::mf/private true} [{:keys [id]}] @@ -978,10 +983,21 @@ :class (stl/css :item-arrow)}]]) (when (contains? cf/flags :mcp) - (let [mcp-enabled? (true? (-> profile :props :mcp-enabled)) + (let [tokens (mf/deref tokens-ref) + expired? (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at + (> (ct/now))) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (-> workspace-local :mcp :connection) mcp-connected? (= mcp-connection "connected") - mcp-error? (= mcp-connection "error")] + mcp-error? (= mcp-connection "error") + + active? (and mcp-enabled? mcp-connected?) + failed? (or (and mcp-enabled? mcp-error?) + (true? expired?))] + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] @@ -993,8 +1009,8 @@ [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.mcp")] [:span {:class (stl/css-case :item-indicator true - :active (and mcp-enabled? mcp-connected?) - :failed (and mcp-enabled? mcp-error?))}] + :active active? + :failed failed?)}] [:> icon* {:icon-id i/arrow-right :class (stl/css :item-arrow)}]])) From 8729fed7240a7a150d49b76fa65c02ed6a0d5f64 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 12:52:18 +0100 Subject: [PATCH 15/26] :paperclip: Add opencode and copilot deps on root package.json --- package.json | 5 +- pnpm-lock.yaml | 176 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 150 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index f38f80617d..b35ad1b50a 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,9 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.2", + "@github/copilot": "^1.0.11", "@types/node": "^20.12.7", - "esbuild": "^0.25.9" + "esbuild": "^0.25.9", + "opencode-ai": "^1.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bec7b49e31..4d683348b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,17 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.0.11 + version: 1.0.11 '@types/node': specifier: ^20.12.7 version: 20.19.37 esbuild: specifier: ^0.25.9 version: 0.25.12 + opencode-ai: + specifier: ^1.3.0 + version: 1.3.0 packages: @@ -176,44 +179,44 @@ packages: cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.2': - resolution: {integrity: sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==} + '@github/copilot-darwin-arm64@1.0.11': + resolution: {integrity: sha512-wdKimjtbsVeXqMqQSnGpGBPFEYHljxXNuWeH8EIJTNRgFpAsimcivsFgql3Twq4YOp0AxfsH36icG4IEen30mA==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.2': - resolution: {integrity: sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==} + '@github/copilot-darwin-x64@1.0.11': + resolution: {integrity: sha512-VeuPv8rzBVGBB8uDwMEhcHBpldoKaq26yZ5YQm+G9Ka5QIF+1DMah8ZNRMVsTeNKkb1ji9G8vcuCsaPbnG3fKg==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.2': - resolution: {integrity: sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==} + '@github/copilot-linux-arm64@1.0.11': + resolution: {integrity: sha512-/d8p6RlFYKj1Va2hekFIcYNMHWagcEkaxgcllUNXSyQLnmEtXUkaWtz62VKGWE+n/UMkEwCB6vI2xEwPTlUNBQ==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.2': - resolution: {integrity: sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==} + '@github/copilot-linux-x64@1.0.11': + resolution: {integrity: sha512-UujTRO3xkPFC1CybchBbCnaTEAG6JrH0etIst07JvfekMWgvRxbiCHQPpDPSzBCPiBcGu0gba0/IT+vUCORuIw==} cpu: [x64] os: [linux] hasBin: true - '@github/copilot-win32-arm64@1.0.2': - resolution: {integrity: sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==} + '@github/copilot-win32-arm64@1.0.11': + resolution: {integrity: sha512-EOW8HUM+EmnHEZEa+iUMl4pP1+2eZUk2XCbynYiMehwX9sidc4BxEHp2RuxADSzFPTieQEWzgjQmHWrtet8pQg==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.2': - resolution: {integrity: sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==} + '@github/copilot-win32-x64@1.0.11': + resolution: {integrity: sha512-fKGkSNamzs3h9AbmswNvPYJBORCb2Y8CbusijU3C7fT3ohvqnHJwKo5iHhJXLOKZNOpFZgq9YKha410u9sIs6Q==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.2': - resolution: {integrity: sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==} + '@github/copilot@1.0.11': + resolution: {integrity: sha512-cptVopko/tNKEXyBP174yBjHQBEwg6CqaKN2S0M3J+5LEB8u31bLL75ioOPd+5vubqBrA0liyTdcHeZ8UTRbmg==} hasBin: true '@types/node@20.19.37': @@ -224,6 +227,70 @@ packages: engines: {node: '>=18'} hasBin: true + opencode-ai@1.3.0: + resolution: {integrity: sha512-il/dC3B55m5mZV2u72emfPqkZBTzrlZwqGI4Ds5Ld6kt2LTUzBZtKB8sOfy7Bmw2qIel0hLZdoKc8wxLjaXQDw==} + hasBin: true + + opencode-darwin-arm64@1.3.0: + resolution: {integrity: sha512-OB+yl/BZkjQhnjjFc+KT57iqhPlXNq3E0oIcHHlGiG63L2LTY3zfi9OhzaoemL+or2CWnpCITUe91yTAddiSEQ==} + cpu: [arm64] + os: [darwin] + + opencode-darwin-x64-baseline@1.3.0: + resolution: {integrity: sha512-Th5yiWOSDeEcjnKWhR8b267Uf8r+jwLFhv30JK4x07Zdmu3Jjjr6TdMvjLgEOv3PWmHf/1yYz22Xachb+QST0A==} + cpu: [x64] + os: [darwin] + + opencode-darwin-x64@1.3.0: + resolution: {integrity: sha512-jivDUpmhzkT7WZp7pXVSb9fdnEVuhKBsnve/9fIkI/UFHxomiZ2NIaNRbHxG26PYT9a1IR4D5QvXBq623g2Mnw==} + cpu: [x64] + os: [darwin] + + opencode-linux-arm64-musl@1.3.0: + resolution: {integrity: sha512-EmXBHyRSzWCnD/KDpaSi8ldgjOa+1t5c5tRASyL/lnbinsrZekxub3lI+oxRvKJXESKdgq9EP4gkp6t2fqGsFw==} + cpu: [arm64] + os: [linux] + + opencode-linux-arm64@1.3.0: + resolution: {integrity: sha512-rWEEKo4oqgJ/zk670ywg6uhEPwbUIQCwYCeh+xJ3IlgPltQNiIjqUbzbRqAmEfI1Uj9DCdbZ2TUtHayRv8umKw==} + cpu: [arm64] + os: [linux] + + opencode-linux-x64-baseline-musl@1.3.0: + resolution: {integrity: sha512-sb7LyPlf+5/t4pQ3whcHPVlb7R7SRY0Bgjgy55amEs3xRuKnC3BfSoj8CAoY50M/yVAbOj0haoxu4LFixljwNw==} + cpu: [x64] + os: [linux] + + opencode-linux-x64-baseline@1.3.0: + resolution: {integrity: sha512-STZtcgGgeRlaFCmkk+mNm+01d02JCzCPvP9kWwNpRF6FBGTcFZ97MxEoGvk+7mEqMueImVQZOR21NiYN6anQhw==} + cpu: [x64] + os: [linux] + + opencode-linux-x64-musl@1.3.0: + resolution: {integrity: sha512-Jc/EbYgqmT2J2WLPm7EQWBYfSqetWTrI4Ipc4KFrSB/LbM/7lfXkjpemjQaYNlDTVkvPXaUPFJUpisH64xZ+4g==} + cpu: [x64] + os: [linux] + + opencode-linux-x64@1.3.0: + resolution: {integrity: sha512-U9aS0wl0uBDxXncqSYhYBDDQP2ZwiTiuJSLM6MgtFJTbUXuTZZCKmQ8p7C5/+Nxpl4sY5xK+ZaCJcS3k3WGN3g==} + cpu: [x64] + os: [linux] + + opencode-windows-arm64@1.3.0: + resolution: {integrity: sha512-3iWo9lOctaWQ+8QHRKszINPTLjLtb0ztzedlvdY5HAiot9MUK/G5MHeskutxQ7sMvTACiAp02ey+Ml/f/jyf7Q==} + cpu: [arm64] + os: [win32] + + opencode-windows-x64-baseline@1.3.0: + resolution: {integrity: sha512-pYuY+9LqPLB/GrlZQr67Cl8RlV6vcay4fW8L3TjabwJOinFMDX9OpNo+DkdKJW7YtPtHD78cXaNDEV8tv9Nx2A==} + cpu: [x64] + os: [win32] + + opencode-windows-x64@1.3.0: + resolution: {integrity: sha512-iFd/6GwfM3jlI2tOb3f12m5ddDY8Ug2HiUU1xmxWJvDnbDBdftlHrzD5twlbIHnKoGvohepX8iWk+A/UN2cXKQ==} + cpu: [x64] + os: [win32] + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -307,32 +374,32 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@github/copilot-darwin-arm64@1.0.2': + '@github/copilot-darwin-arm64@1.0.11': optional: true - '@github/copilot-darwin-x64@1.0.2': + '@github/copilot-darwin-x64@1.0.11': optional: true - '@github/copilot-linux-arm64@1.0.2': + '@github/copilot-linux-arm64@1.0.11': optional: true - '@github/copilot-linux-x64@1.0.2': + '@github/copilot-linux-x64@1.0.11': optional: true - '@github/copilot-win32-arm64@1.0.2': + '@github/copilot-win32-arm64@1.0.11': optional: true - '@github/copilot-win32-x64@1.0.2': + '@github/copilot-win32-x64@1.0.11': optional: true - '@github/copilot@1.0.2': + '@github/copilot@1.0.11': optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.2 - '@github/copilot-darwin-x64': 1.0.2 - '@github/copilot-linux-arm64': 1.0.2 - '@github/copilot-linux-x64': 1.0.2 - '@github/copilot-win32-arm64': 1.0.2 - '@github/copilot-win32-x64': 1.0.2 + '@github/copilot-darwin-arm64': 1.0.11 + '@github/copilot-darwin-x64': 1.0.11 + '@github/copilot-linux-arm64': 1.0.11 + '@github/copilot-linux-x64': 1.0.11 + '@github/copilot-win32-arm64': 1.0.11 + '@github/copilot-win32-x64': 1.0.11 '@types/node@20.19.37': dependencies: @@ -367,4 +434,55 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + opencode-ai@1.3.0: + optionalDependencies: + opencode-darwin-arm64: 1.3.0 + opencode-darwin-x64: 1.3.0 + opencode-darwin-x64-baseline: 1.3.0 + opencode-linux-arm64: 1.3.0 + opencode-linux-arm64-musl: 1.3.0 + opencode-linux-x64: 1.3.0 + opencode-linux-x64-baseline: 1.3.0 + opencode-linux-x64-baseline-musl: 1.3.0 + opencode-linux-x64-musl: 1.3.0 + opencode-windows-arm64: 1.3.0 + opencode-windows-x64: 1.3.0 + opencode-windows-x64-baseline: 1.3.0 + + opencode-darwin-arm64@1.3.0: + optional: true + + opencode-darwin-x64-baseline@1.3.0: + optional: true + + opencode-darwin-x64@1.3.0: + optional: true + + opencode-linux-arm64-musl@1.3.0: + optional: true + + opencode-linux-arm64@1.3.0: + optional: true + + opencode-linux-x64-baseline-musl@1.3.0: + optional: true + + opencode-linux-x64-baseline@1.3.0: + optional: true + + opencode-linux-x64-musl@1.3.0: + optional: true + + opencode-linux-x64@1.3.0: + optional: true + + opencode-windows-arm64@1.3.0: + optional: true + + opencode-windows-x64-baseline@1.3.0: + optional: true + + opencode-windows-x64@1.3.0: + optional: true + undici-types@6.21.0: {} From 1539c074b4294065ede4e400c2698688c0e60572 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 24 Mar 2026 13:48:24 +0100 Subject: [PATCH 16/26] :bug: Fix problem with margins in grid (#8748) --- render-wasm/src/shapes/modifiers/grid_layout.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 7ef2cb447b..3599f1e595 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -750,7 +750,8 @@ pub fn reflow_grid_layout( let mut new_width = child_bounds.width(); if child.is_layout_horizontal_fill() { let margin_left = child.layout_item.map(|i| i.margin_left).unwrap_or(0.0); - new_width = cell.width - margin_left; + let margin_right = child.layout_item.map(|i| i.margin_right).unwrap_or(0.0); + new_width = cell.width - margin_left - margin_right; let min_width = child.layout_item.and_then(|i| i.min_w).unwrap_or(MIN_SIZE); let max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE); new_width = new_width.clamp(min_width, max_width); @@ -759,7 +760,8 @@ pub fn reflow_grid_layout( let mut new_height = child_bounds.height(); if child.is_layout_vertical_fill() { let margin_top = child.layout_item.map(|i| i.margin_top).unwrap_or(0.0); - new_height = cell.height - margin_top; + let margin_bottom = child.layout_item.map(|i| i.margin_bottom).unwrap_or(0.0); + new_height = cell.height - margin_top - margin_bottom; let min_height = child.layout_item.and_then(|i| i.min_h).unwrap_or(MIN_SIZE); let max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE); new_height = new_height.clamp(min_height, max_height); From d863c7065f603048925379b3ef580143911892b7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 12:05:50 +0000 Subject: [PATCH 17/26] :bug: Fix null text crash on paste in text editor The splitTextIntoTextBlocks function in @penpot/draft-js called .split() on the text parameter without a null check. When pasting content without text data (e.g., images only), Draft.js passes null to handlePastedText, causing a TypeError. Signed-off-by: Andrey Antukh --- frontend/packages/draft-js/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index 23bd20af01..ed8e93932f 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -366,6 +366,9 @@ export function getInlineStyle(state, blockKey, offset) { const NEWLINE_REGEX = /\r\n?|\n/g; function splitTextIntoTextBlocks(text) { + if (text == null) { + return []; + } return text.split(NEWLINE_REGEX); } From 56f1fcdb53be62629331032496f91b8cec375e1c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 13:00:28 +0000 Subject: [PATCH 18/26] :bug: Fix crash when pasting image into text editor When pasting an image (with no text content) into the text editor, Draft.js calls handlePastedText with null/empty text. The previous fix guarded splitTextIntoTextBlocks against null, but insertText still attempted to build a fragment from an empty block array, causing Modifier.replaceWithFragment to crash with 'Cannot read properties of undefined (reading getLength)'. Fix insertText to return the original state unchanged when there are no text blocks to insert. Also guard handle-pasted-text in the ClojureScript editor to skip the insert-text call entirely when text is nil or empty. Signed-off-by: Andrey Antukh --- frontend/packages/draft-js/index.js | 4 ++++ .../app/main/ui/workspace/shapes/text/editor.cljs | 13 +++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index ed8e93932f..84b7190537 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -375,6 +375,10 @@ function splitTextIntoTextBlocks(text) { export function insertText(state, text, attrs, inlineStyles) { const blocks = splitTextIntoTextBlocks(text); + if (blocks.length === 0) { + return state; + } + const character = CharacterMetadata.create({style: OrderedSet(inlineStyles)}); let blockArray = DraftPasteProcessor.processText( diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 135df9ecb3..616cd17b98 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -221,12 +221,13 @@ handle-pasted-text (fn [text _ _] - (let [current-block-styles (ted/get-editor-current-block-data state) - inline-styles (ted/get-editor-current-inline-styles state) - style (merge current-block-styles inline-styles) - state (-> (ted/insert-text state text style) - (handle-change))] - (st/emit! (dwt/update-editor-state shape state))) + (when (seq text) + (let [current-block-styles (ted/get-editor-current-block-data state) + inline-styles (ted/get-editor-current-inline-styles state) + style (merge current-block-styles inline-styles) + state (-> (ted/insert-text state text style) + (handle-change))] + (st/emit! (dwt/update-editor-state shape state)))) "handled")] (mf/use-layout-effect on-mount) From 750e8a9d513b3a668bad3f1f4c09a13906f40f93 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 15:35:32 +0100 Subject: [PATCH 19/26] :bug: Fix dissoc error when detaching stroke color from library (#8738) * :bug: Fix dissoc error when detaching stroke color from library The detach-value function in color-row was only passing index to on-detach, but the stroke's on-color-detach handler expects both index and color arguments. This caused a protocol error when trying to dissoc from a number instead of a map. Signed-off-by: Andrey Antukh * :bug: Fix crash when detaching color asset from stroke The color_row detach-value callback calls on-detach with (index, color), but stroke_row's local on-color-detach wrapper only took a single argument (fn [color] ...), so it received index as color and passed it to stroke.cljs which then called (dissoc index :ref-id :ref-file), crashing with 'No protocol method IMap.-dissoc defined for type number'. Fix the wrapper to accept (fn [_ color] ...) so it correctly ignores the index passed by color_row (it already has index in the closure) and forwards the actual color map to the parent handler. Signed-off-by: Andrey Antukh --------- Signed-off-by: Andrey Antukh --- .../app/main/ui/workspace/sidebar/options/rows/color_row.cljs | 4 ++-- .../main/ui/workspace/sidebar/options/rows/stroke_row.cljs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index 7e6e47241a..71e6ec2eb9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -205,10 +205,10 @@ detach-value (mf/use-fn - (mf/deps on-detach index) + (mf/deps on-detach index color) (fn [_] (when on-detach - (on-detach index)))) + (on-detach index color)))) handle-select (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index ec5770eabb..7cb0956d11 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -80,7 +80,7 @@ on-color-detach (mf/use-fn (mf/deps index on-color-detach) - (fn [color] + (fn [_ color] (on-color-detach index color))) on-remove From 1f7afcebe31d8e3a79cf871ba5d3c7ac2bc46d3c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 19 Mar 2026 11:22:04 +0100 Subject: [PATCH 20/26] :sparkles: Apply throwValidationErrors flag during MCP code executions #8682 --- .../src/task-handlers/ExecuteCodeTaskHandler.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 213333bab9..85ed5a32d1 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -195,16 +195,19 @@ export class ExecuteCodeTaskHandler extends TaskHandler { const context = this.context; const code = task.params.code; - // set the penpot.flags.naturalChildOrdering to true during code execution. - // NOTE: This significantly simplifies API usage (see ) - // TODO: Remove ts-ignore once Penpot types have been updated - let originalNaturalChildOrdering: any; + // set the flags naturalChildOrdering and throwValidationErrors to true during code execution. + // TODO: Remove all ts-ignore once Penpot types have been updated + let originalNaturalChildOrdering: any, originalThrowValidationErrors: any; // @ts-ignore if (penpot.flags) { // @ts-ignore originalNaturalChildOrdering = penpot.flags.naturalChildOrdering; // @ts-ignore penpot.flags.naturalChildOrdering = true; + // @ts-ignore + originalThrowValidationErrors = penpot.flags.throwValidationErrors; + // @ts-ignore + penpot.flags.throwValidationErrors = true; } else { // TODO: This can be removed once `flags` has been merged to PROD throw new Error( @@ -224,9 +227,11 @@ export class ExecuteCodeTaskHandler extends TaskHandler { return fn(...Object.values(ctx)); })(context); } finally { - // restore the original value of penpot.flags.naturalChildOrdering + // restore the original value of the flags // @ts-ignore penpot.flags.naturalChildOrdering = originalNaturalChildOrdering; + // @ts-ignore + penpot.flags.throwValidationErrors = originalThrowValidationErrors; } console.log("Code execution result:", result); From 7b5699b59fd492368b928021bff8048857d7020c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 24 Mar 2026 14:23:00 +0100 Subject: [PATCH 21/26] :sparkles: Improve instructions on Text elements --- .../server/data/initial_instructions.md | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index c85441a977..2d0be0ac55 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -138,13 +138,23 @@ Boards can have layout systems that automatically control the positioning and sp # Text Elements -The rendered content of a `Text` element is given by the `characters` property. - -To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, -it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. -The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height". -`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing! -The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. +`Text` elements: + * The text to be rendered is given by the `characters` property. + * To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, + it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. + * Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height". + `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing! + The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. + * Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive + * Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle` + - To discover valid values, check available fonts in `penpot.fonts: FontContext` + - `FontContext` provides `Font` instances; each font has property `variants: FontVariant[]` + - Example: Determine available weights for a font using `penpot.fonts.findByName("Laila").variants.map(v => v.fontWeight)` + - To apply a `Font` to a `Text` instance and set all font properties at once: + - `font.applyToText(text: Text, variant?: FontVariant)` + - `applyToRange(range: TextRange, variant?: FontVariant)` + * Further writable properties: `align`, `verticalAlign`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration` (see API info) + * Method `applyTypography(typography: LibraryTypography)` # The `penpot` and `penpotUtils` Objects, Exploring Designs From 38f2ec1339eea54684d8b4af32c6f8eb844fe1c0 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 24 Mar 2026 14:23:39 +0100 Subject: [PATCH 22/26] :paperclip: Update Serena project file --- mcp/.serena/project.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index abb5cab52e..e5729836cd 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -150,3 +150,17 @@ 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. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} From ccaeb493543a6328105ef9b6c005d6bc436e7e43 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 11 Mar 2026 17:25:56 +0100 Subject: [PATCH 23/26] :books: Add instructions on MCP usage via npx #8535 --- mcp/README.md | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index e3feeb80ff..eaf8b22081 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,13 +50,33 @@ Follow the steps below to enable the integration. ### Prerequisites -The project requires [Node.js](https://nodejs.org/) (tested with v22.x). -Following the installation of Node.js, the tools `corepack` and `npx` -should be available in your terminal. +The project requires [Node.js](https://nodejs.org/) (tested with v22.x). + +### 1. Starting the MCP Server and the Plugin Server + +#### Running a Released Version via npx + +The easiest way to launch the servers is to use `npx` to run the appropriate +version that matches your Penpot version. + +* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: + ```shell + npx -y @penpot/mcp@">=0" + ``` +* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: + ```shell + npx -y @penpot/mcp@"*" + ``` + +Once the servers are running, continue with step 2. + +#### Running the Source Version from the Repository + +The tools `corepack` and `npx` should be available in your terminal. On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts. -### 0. Clone the Appropriate Branch of the Repository +##### Clone the Appropriate Branch of the Repository > [!IMPORTANT] > The branches are subject to change in the future. @@ -65,13 +85,13 @@ On Windows, use the Git Bash terminal to ensure compatibility with the provided Clone the Penpot repository, using the proper branch depending on the version of Penpot you want to use the MCP server with. - * For released versions of Penpot, use the `mcp-prod` branch: + * For the current Penpot release 2.14, use the `mcp-prod-2.14.0` branch: ```shell - git clone https://github.com/penpot/penpot.git --branch mcp-prod --depth 1 + git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.0 --depth 1 ``` - * For the latest development version of Penpot, use the `develop` branch: + * For the latest development version of Penpot (including the MCP beta-test), use the `develop` branch: ```shell git clone https://github.com/penpot/penpot.git --branch develop --depth 1 @@ -83,7 +103,7 @@ Then change into the `mcp` directory: cd penpot/mcp ``` -### 1. Build & Launch the MCP Server and the Plugin Server +##### Build & Launch the MCP Server and the Plugin Server If it's your first execution, install the required dependencies. (If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.) From 44689d3f9c73027f73d24a2ca282f9045fa83250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 24 Mar 2026 15:29:54 +0100 Subject: [PATCH 24/26] :bug: Fix internal error on invalid max-h/max-w values (wasm) --- render-wasm/src/wasm/layouts.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 904ce63cc5..81ea59240d 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -63,10 +63,11 @@ pub extern "C" fn set_layout_data( let h_sizing = RawSizing::from(h_sizing); let v_sizing = RawSizing::from(v_sizing); - let max_h = if has_max_h { Some(max_h) } else { None }; - let min_h = if has_min_h { Some(min_h) } else { None }; - let max_w = if has_max_w { Some(max_w) } else { None }; - let min_w = if has_min_w { Some(min_w) } else { None }; + let max_h = has_max_h.then(|| max_h.max(0.01)); + let min_h = has_min_h.then(|| min_h.clamp(0.01, max_h.unwrap_or(f32::INFINITY))); + let max_w = has_max_w.then(|| max_w.max(0.01)); + let min_w = has_min_w.then(|| min_w.clamp(0.01, max_w.unwrap_or(f32::INFINITY))); + let z_index = if z_index != 0 { Some(z_index) } else { None }; let raw_align_self = align::RawAlignSelf::from(align_self); From b6e300a6c7a3fbf9b606fcfac7b31285dc9fb162 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 24 Mar 2026 15:36:08 +0100 Subject: [PATCH 25/26] :bug: Fix plugins addToken schema validation --- frontend/src/app/plugins/tokens.cljs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ec1ebf6b71..13888f0481 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -287,15 +287,19 @@ :addToken {:enumerable false :schema (fn [args] - [:tuple (-> (cfo/make-token-schema - (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) + (let [tokens-tree (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id) + ;; Convert to the adecuate format for schema + (ctob/tokens-tree))] + [:tuple (-> (cfo/make-token-schema + tokens-tree + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] (let [tokens-lib (u/locate-tokens-lib file-id) From cc03f3f884f3ffbc9a71193131d6ac5d5c5c609b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 17:49:24 +0100 Subject: [PATCH 26/26] :books: Add minor improvements to ai agents documentation --- AGENTS.md | 168 ++++++++++++------------------------------ backend/AGENTS.md | 33 +++++---- common/AGENTS.md | 27 ++++--- frontend/AGENTS.md | 73 +++++++++--------- opencode.json | 2 +- render-wasm/AGENTS.md | 2 +- 6 files changed, 114 insertions(+), 191 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59c4ac0d26..bcb947da47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,139 +1,63 @@ -# IA Agent guide for Penpot monorepo +# AI Agent Guide -This document provides comprehensive context and guidelines for AI -agents working on this repository. +This document provides the core context and operating guidelines for AI agents +working in this repository. -CRITICAL: When you encounter a file reference (e.g., -@rules/general.md), use your Read tool to load it on a need-to-know -basis. They're relevant to the SPECIFIC task at hand. +## Before You Start +Before responding to any user request, you must: -## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS +1. Read this file completely. +2. Identify which modules are affected by the task. +3. Load the `AGENTS.md` file **only** for each affected module (see the + architecture table below). Not all modules have an `AGENTS.md` — verify the + file exists before attempting to read it. +4. Do **not** load `AGENTS.md` files for unrelated modules. -Before responding to ANY user request, you MUST: +## Role: Senior Software Engineer -1. **READ** the CONTRIBUTING.md file -2. **READ** this file and has special focus on your ROLE. +You are a high-autonomy Senior Full-Stack Software Engineer. You have full +permission to navigate the codebase, modify files, and execute commands to +fulfill your tasks. Your goal is to solve complex technical tasks with high +precision while maintaining a strong focus on maintainability and performance. +### Operational Guidelines -## ROLE: SENIOR SOFTWARE ENGINEER +1. Before writing code, describe your plan. If the task is complex, break it + down into atomic steps. +2. Be concise and autonomous. +3. Do **not** touch unrelated modules unless the task explicitly requires it. +4. Commit only when explicitly asked. Follow the commit format rules in + `CONTRIBUTING.md`. +5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects + `.gitignore` by default. -You are a high-autonomy Senior Software Engineer. You have full -permission to navigate the codebase, modify files, and execute -commands to fulfill your tasks. Your goal is to solve complex -technical tasks with high precision, focusing on maintainability and -performance. +## Architecture Overview +Penpot is an open-source design tool composed of several modules: -### OPERATIONAL GUIDELINES +| Directory | Language | Purpose | Has `AGENTS.md` | +|-----------|----------|---------|:----------------:| +| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes | +| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes | +| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes | +| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes | +| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No | +| `mcp/` | TypeScript | Model Context Protocol integration | No | +| `plugins/` | TypeScript | Plugin runtime and example plugins | No | -1. Always begin by analyzing this document and understand the - architecture and read the additional context from AGENTS.md of the - affected modules. -2. Before writing code, describe your plan. If the task is complex, - break it down into atomic steps. -3. Be concise and autonomous as possible in your task. -4. Commit only if it explicitly asked, and use the CONTRIBUTING.md - document to understand the commit format guidelines. -5. Do not touch unrelated modules if not proceed or not explicitly - asked (per example you probably do not need to touch and read - docker/ directory unless the task explicitly requires it) -6. When searching code, always use `ripgrep` (rg) instead of grep if - available, as it respects `.gitignore` by default. +Some submodules use `pnpm` workspaces. The root `package.json` and +`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`. - -## ARCHITECTURE OVERVIEW - -Penpot is a full-stack design tool composed of several distinct -components separated in modules and subdirectories: - -| Component | Language | Role | IA Agent CONTEXT | -|-----------|----------|------|---------------- -| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | @frontend/AGENTS.md | -| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | @backend/AGENTS.md | -| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | @common/AGENTS.md | -| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | @exporter/AGENTS.md | -| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia | @render-wasm/AGENTS.md | -| `mcp/` | TypeScript | Model Context Protocol integration | @mcp/AGENTS.md | -| `plugins/` | TypeScript | Plugin runtime and example plugins | @plugins/AGENTS.md | - -Several of the mentionend submodules are internall managed with `pnpm` workspaces. - - -## COMMIT FORMAT - -We have very precise rules on how our git commit messages must be -formatted. - -The commit message format is: +### Module Dependency Graph ``` - - -[body] - -[footer] +frontend ──> common +backend ──> common +exporter ──> common +frontend ──> render-wasm (loads compiled WASM) ``` -Where type is: - -- :bug: `:bug:` a commit that fixes a bug -- :sparkles: `:sparkles:` a commit that adds an improvement -- :tada: `:tada:` a commit with a new feature -- :recycle: `:recycle:` a commit that introduces a refactor -- :lipstick: `:lipstick:` a commit with cosmetic changes -- :ambulance: `:ambulance:` a commit that fixes a critical bug -- :books: `:books:` a commit that improves or adds documentation -- :construction: `:construction:` a WIP commit -- :boom: `:boom:` a commit with breaking changes -- :wrench: `:wrench:` a commit for config updates -- :zap: `:zap:` a commit with performance improvements -- :whale: `:whale:` a commit for Docker-related stuff -- :paperclip: `:paperclip:` a commit with other non-relevant changes -- :arrow_up: `:arrow_up:` a commit with dependency updates -- :arrow_down: `:arrow_down:` a commit with dependency downgrades -- :fire: `:fire:` a commit that removes files or code -- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates - translations - -The commit should contain a sign-off at the end of the patch/commit -description body. It can be automatically added by adding the `-s` -parameter to `git commit`. - -This is an example of what the line should look like: - -``` -Signed-off-by: Andrey Antukh -``` - -Please, use your real name (sorry, no pseudonyms or anonymous -contributions are allowed). - -CRITICAL: The commit Signed-off-by is mandatory and should match the commit author. - -Each commit should have: - -- A concise subject using the imperative mood. -- The subject should capitalize the first letter, omit the period - at the end, and be no longer than 65 characters. -- A blank line between the subject line and the body. -- An entry in the CHANGES.md file if applicable, referencing the - GitHub or Taiga issue/user story using these same rules. - -Examples of good commit messages: - -- `:bug: Fix unexpected error on launching modal` -- `:bug: Set proper error message on generic error` -- `:sparkles: Enable new modal for profile` -- `:zap: Improve performance of dashboard navigation` -- `:wrench: Update default backend configuration` -- `:books: Add more documentation for authentication process` -- `:ambulance: Fix critical bug on user registration process` -- `:tada: Add new approach for user registration` - -More info: - - - https://gist.github.com/parmentf/035de27d6ed1dce0b36a - - https://gist.github.com/rxaviers/7360908 - - +`common` is referenced as a local dependency (`{:local/root "../common"}`) by +both `frontend` and `backend`. Changes to `common` can therefore affect multiple +modules — test across consumers when modifying shared code. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 278df26e52..b4ac2ac1dd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -7,8 +7,8 @@ Redis for messaging/caching. ## General Guidelines -This is a golden rule for backend development standards. To ensure consistency -across the Penpot JVM stack, all contributions must adhere to these criteria: +To ensure consistency across the Penpot JVM stack, all contributions must adhere +to these criteria: ### 1. Testing & Validation @@ -16,14 +16,14 @@ across the Penpot JVM stack, all contributions must adhere to these criteria: tests in `test/backend_tests/` must be added or updated. * **Execution:** - * **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific task. - * **Regression:** Run `clojure -M:dev:test` for ensure the suite passes without regressions in related functional areas. + * **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace. + * **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas. ### 2. Code Quality & Formatting * **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`) * **Formatting:** All the code must pass the formatting check (run `pnpm run - check-fmt`). Use the `pnpm run fmt` fix the formatting issues. Avoid "dirty" + check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty" diffs caused by unrelated whitespace changes. * **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in performance-critical paths to avoid reflection overhead. @@ -40,18 +40,18 @@ namespaces structure: - `app.db.*` – Database layer - `app.tasks.*` – Background job tasks - `app.main` – Integrant system setup and entrypoint -- `app.loggers` – Internal loggers (auditlog, mattermost, etc) (do not be confused with `app.common.loggin`) +- `app.loggers` – Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`) ### RPC -The PRC methods are implement in a some kind of multimethod structure using -`app.util.serivices` namespace. The main RPC methods are collected under +The RPC methods are implemented using a multimethod-like structure via the +`app.util.services` namespace. The main RPC methods are collected under `app.rpc.commands` namespace and exposed under `/api/rpc/command/`. -The RPC method accepts POST and GET requests indistinctly and uses `Accept` -header for negotiate the response encoding (which can be transit, the defaut or -plain json). It also accepts transit (defaut) or json as input, which should be -indicated using `Content-Type` header. +The RPC method accepts POST and GET requests indistinctly and uses the `Accept` +header to negotiate the response encoding (which can be Transit — the default — +or plain JSON). It also accepts Transit (default) or JSON as input, which should +be indicated using the `Content-Type` header. The main convention is: use `get-` prefix on RPC name when we want READ operation. @@ -107,7 +107,7 @@ are config maps with `::ig/ref` for dependencies. Components implement (db/insert! conn :table row))) ``` -Almost all methods on `app.db` namespace accepts `pool`, `conn` or +Almost all methods in the `app.db` namespace accept `pool`, `conn`, or `cfg` as params. Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup. @@ -116,7 +116,7 @@ Migrations live in `src/app/migrations/` as numbered SQL files. They run automat ### Error Handling The exception helpers are defined on Common module, and are available under -`app.commin.exceptions` namespace. +`app.common.exceptions` namespace. Example of raising an exception: @@ -132,10 +132,11 @@ Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:inte ### Performance Macros (`app.common.data.macros`) -Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: +Always prefer these macros over their `clojure.core` equivalents — they provide +optimized implementations: ```clojure -(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys +(dm/select-keys m [:a :b]) ;; faster than core/select-keys (dm/get-in obj [:a :b :c]) ;; faster than core/get-in (dm/str "a" "b" "c") ;; string concatenation ``` diff --git a/common/AGENTS.md b/common/AGENTS.md index 996a7f4953..2659b83939 100644 --- a/common/AGENTS.md +++ b/common/AGENTS.md @@ -1,14 +1,13 @@ # Penpot Common – Agent Instructions -A shared module with code written in Clojure, ClojureScript and -JavaScript. Contains multplatform code that can be used and executed -from frontend, backend or exporter modules. It uses clojure reader -conditionals for specify platform specific implementation. +A shared module with code written in Clojure, ClojureScript, and +JavaScript. Contains multiplatform code that can be used and executed +from the frontend, backend, or exporter modules. It uses Clojure reader +conditionals to specify platform-specific implementations. ## General Guidelines -This is a golden rule for common module development. To ensure -consistency across the penpot stack, all contributions must adhere to +To ensure consistency across the Penpot stack, all contributions must adhere to these criteria: ### 1. Testing & Validation @@ -16,11 +15,11 @@ these criteria: If code is added or modified in `src/`, corresponding tests in `test/common_tests/` must be added or updated. -* **Environment:** Tests should run in a JS (nodejs) and JVM + * **Environment:** Tests should run in both JS (Node.js) and JVM environments. * **Location:** Place tests in the `test/common_tests/` directory, following the namespace structure of the source code (e.g., `app.common.colors` -> `common-tests.colors-test`). -* **Execution:** The tests should be executed on both: JS (nodejs) and JVM environments +* **Execution:** Tests should be executed on both JS (Node.js) and JVM environments: * **Isolated:** * JS: To run a focused ClojureScript unit test: edit the `test/common_tests/runner.cljs` to narrow the test suite, then @@ -37,8 +36,8 @@ If code is added or modified in `src/`, corresponding tests in * **Formatting:** All code changes must pass the formatting check * Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC * Run `pnpm run check-fmt:js` for JS - * Use the `pnpm run fmt` fix all the formatting issues (`pnpm run - fmt:clj` or `pnpm run fmt:js` for isolated formatting fix) + * Use `pnpm run fmt` to fix all formatting issues (`pnpm run + fmt:clj` or `pnpm run fmt:js` for isolated formatting fix). ## Code Conventions @@ -50,16 +49,16 @@ namespaces structure: - `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas - `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli - `app.common.geom.*` – Geometry and shape transformation helpers -- `app.common.data` – Generic helpers used around all application -- `app.common.math` – Generic math helpers used around all aplication +- `app.common.data` – Generic helpers used across the entire application +- `app.common.math` – Generic math helpers used across the entire application - `app.common.json` – Generic JSON encoding/decoding helpers - `app.common.data.macros` – Performance macros used everywhere ### Reader Conditionals -We use reader conditionals to target for differentiate an -implementation depending on the target platform where code should run: +We use reader conditionals to differentiate implementations depending on the +target platform where the code runs: ```clojure #?(:clj (import java.util.UUID) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index b6f63794cc..b4ad811522 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -1,13 +1,12 @@ # Penpot Frontend – Agent Instructions -ClojureScript based frontend application that uses React, RxJS as main +ClojureScript-based frontend application that uses React and RxJS as its main architectural pieces. - ## General Guidelines -This is a golden rule for frontend development standards. To ensure consistency -across the penpot stack, all contributions must adhere to these criteria: +To ensure consistency across the Penpot stack, all contributions must adhere to +these criteria: ### 1. Testing & Validation @@ -22,7 +21,7 @@ If code is added or modified in `src/`, corresponding tests in running backend. Test are developed using cljs.test. * **Mocks & Stubs:** * Use proper mocks for any side-effecting functions (e.g., API calls, storage access). - * Avoid testing through the UI (DOM), we have e2e tests for that/ + * Avoid testing through the UI (DOM); we have e2e tests for that. * Use `with-redefs` or similar ClojureScript mocking utilities to isolate the logic under test. * **No Flakiness:** Tests must be deterministic. Do not use `setTimeout` or real network calls. Use synchronous mocks for asynchronous workflows where @@ -34,15 +33,15 @@ If code is added or modified in `src/`, corresponding tests in * **Isolated:** To run a focused ClojureScript unit test: edit the `test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm run test`. - * **Regression:** Run `pnpm run test` without modifications on the runner (preferred) + * **Regression:** To run `pnpm run test` without modifications on the runner (preferred) #### Integration Tests (Playwright) Integration tests are developed under `frontend/playwright` directory, we use -mocks for remove communication with backend. +mocks for remote communication with the backend. -You should not add, modify or run the integration tests unless it exlicitly asked for. +You should not add, modify or run the integration tests unless explicitly asked. ``` @@ -50,7 +49,7 @@ pnpm run test:e2e # Playwright e2e tests pnpm run test:e2e --grep "pattern" # Single e2e test by pattern ``` -Ensure everything installed before executing tests with `./scripts/setup` script. +Ensure everything is installed before executing tests with the `./scripts/setup` script. ### 2. Code Quality & Formatting @@ -68,8 +67,8 @@ Ensure everything installed before executing tests with `./scripts/setup` script ### 3. Implementation Rules -* **Logic vs. View:** If logic is embedded in an UI component, extract it into a - function in the same namespace if is only used locally or look for a helper +* **Logic vs. View:** If logic is embedded in a UI component, extract it into a + function in the same namespace if it is only used locally, or look for a helper namespace to make it unit-testable. @@ -113,7 +112,7 @@ State is a single atom managed by a Potok store. Events implement protocols ``` The state is located under `app.main.store` namespace where we have -the `emit!` function responsible of emiting events. +the `emit!` function responsible for emitting events. Example: @@ -128,15 +127,14 @@ Example: (st/emit! (my-event))) ``` -On `app.main.refs` we have reactive references which lookup into the main state -for just inner data or precalculated data. That references are very usefull but -should be used with care because, per example if we have complex operation, this -operation will be executed on each state change, and sometimes is better to have -simple references and use react `use-memo` for more granular memoization. +On `app.main.refs` we have reactive references which look up the main state +for inner data or precalculated data. These references are very useful but +should be used with care because, for example, if we have a complex operation, +this operation will be executed on each state change. Sometimes it is better to +have simple references and use React `use-memo` for more granular memoization. -Prefer helpers from `app.util.dom` instead of using direct dom calls, if no helper is -available, prefer adding a new helper for handling it and the use the -new helper. +Prefer helpers from `app.util.dom` instead of using direct DOM calls. If no +helper is available, prefer adding a new helper and then using it. ### UI Components (React & Rumext: mf/defc) @@ -175,19 +173,20 @@ lifecycle management. These are analogous to standard React hooks: ``` The `mf/use-state` in difference with React.useState, returns an atom-like -object, where you can use `swap!` or `reset!` for to perform an update and -`deref` for get the current value. +object, where you can use `swap!` or `reset!` to perform an update and +`deref` to get the current value. -You also has `mf/deref` hook (which does not follow the `use-` naming pattern) -and it's purpose is watch (subscribe to changes) on atom or derived atom (from -okulary) and get the current value. Is mainly used for subscribe to lenses -defined in `app.main.refs` or (private lenses defined in namespaces). +You also have the `mf/deref` hook (which does not follow the `use-` naming +pattern) and its purpose is to watch (subscribe to changes on) an atom or +derived atom (from okulary) and get the current value. It is mainly used to +subscribe to lenses defined in `app.main.refs` or private lenses defined in +namespaces. Rumext also comes with improved syntax macros as alternative to `mf/use-effect` and `mf/use-memo` functions. Examples: -Example for `mf/with-memo` macro: +Example for `mf/with-effect` macro: ```clj ;; Using functions @@ -221,7 +220,7 @@ Example for `mf/with-memo` macro: (filterv #(= team-id (:team-id %))))) ``` -Prefer using the macros for it syntax simplicity. +Prefer using the macros for their syntax simplicity. #### 4. Component Usage (Hiccup Syntax) @@ -282,22 +281,22 @@ CSS modules pattern): - If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" as *; padding: px2rem(23);`. - Do **not** create new SCSS variables for one-off values. -- Use physical directions with logical ones to support RTL/LTR naturally. - - ❌ `margin-left`, `padding-right`, `left`, `right`. - - ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. -- Always use the `use-typography` mixin from `ds/typography.scss`. - - ✅ `@include t.use-typography("title-small");` +- Use physical directions with logical ones to support RTL/LTR naturally: + - Avoid: `margin-left`, `padding-right`, `left`, `right`. + - Prefer: `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. +- Always use the `use-typography` mixin from `ds/typography.scss`: + - Example: `@include t.use-typography("title-small");` - Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. - Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or legacy color variables. -- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like +- Use mixins only from `ds/mixins.scss`. Avoid legacy mixins like `@include flexCenter;`. Write standard CSS (flex/grid) instead. - Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as *;` (Use `as *` to expose variables directly). - Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: - - ❌ `.card { .title { ... } }` - - ✅ `.card-title { ... }` + - Avoid: `.card { .title { ... } }` + - Prefer: `.card-title { ... }` - Leverage component-level CSS variables for state changes (hover/focus) instead of rewriting properties. @@ -324,5 +323,5 @@ Always prefer these macros over their `clojure.core` equivalents — they compil ### Configuration `src/app/config.clj` reads globally defined variables and exposes precomputed -configuration vars ready to be used from other parts of the application +configuration values ready to be used from other parts of the application. diff --git a/opencode.json b/opencode.json index 0ab56b6192..6376bc70e5 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1,4 @@ { "$schema": "https://opencode.ai/config.json", - "instructions": ["CONTRIBUTING.md", "AGENTS.md"] + "instructions": ["AGENTS.md"] } diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md index dfe9c3def9..511b7da0c9 100644 --- a/render-wasm/AGENTS.md +++ b/render-wasm/AGENTS.md @@ -59,4 +59,4 @@ parent/child relationships are tracked separately. The WASM module is loaded by `app.render-wasm.*` namespaces in the frontend. ClojureScript calls exported Rust functions to push shape data, then calls `render_frame`. Do not change export function -signatures without updating the ClojureScript bridge. +signatures without updating the corresponding ClojureScript bridge.