From 8645801eedf068c1ed793e81f0caaefb626ffea0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 25 Jun 2026 11:33:59 +0200 Subject: [PATCH 01/14] :paperclip: Update changelog --- CHANGES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 831996000e..2446702c3b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.16.2 + +### :bug: Bugs fixed + +- Fix error 500 when submitting the contact form [#10178](https://github.com/penpot/penpot/issues/10178) (PR: [#10419](https://github.com/penpot/penpot/pull/10419)) +- Fix text editor modifying content and detaching applied typography tokens [#10389](https://github.com/penpot/penpot/issues/10389) (PR: [#10402](https://github.com/penpot/penpot/pull/10402)) + ## 2.16.1 ### :sparkles: New features & Enhancements From d323aa96930d905156f5580f926ee13433fa85ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 25 Jun 2026 11:50:58 +0200 Subject: [PATCH 02/14] :bug: Fix guides not clipping (#10423) --- render-wasm/src/render/rulers.rs | 2 +- render-wasm/src/render/ui.rs | 8 +++++++- render-wasm/src/render/ui/guides.rs | 25 +++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/render/rulers.rs b/render-wasm/src/render/rulers.rs index 510979a2f8..582469e8ee 100644 --- a/render-wasm/src/render/rulers.rs +++ b/render-wasm/src/render/rulers.rs @@ -13,7 +13,7 @@ use super::fonts::FontStore; use crate::state::RulerState; use crate::view::Viewbox; -const RULER_AREA_SIZE: f32 = 22.0; +pub const RULER_AREA_SIZE: f32 = 22.0; const RULER_TICK_OFFSET: f32 = 15.0; const RULER_TICK_LEN: f32 = 4.0; const RULER_TICK_GAP: f32 = 2.0; diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 3b69955a82..cf3f2d7963 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -65,13 +65,19 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let viewbox = render_state.viewbox; let ruler_state = render_state.rulers; rulers::render(canvas, viewbox, &render_state.fonts, &ruler_state); - // TODO: pass guides data here + + // Width of the ruler bars in document coordinates. + // Note that when rulers are hidden, guides are not shown either, so we + // can use a fixed value here. + let ruler_width = rulers::RULER_AREA_SIZE / viewbox.zoom; + let (horizontal, vertical) = get_ui_state().guides(); guides::render( canvas, zoom, render_state.options.dpr, viewbox.area, + ruler_width, horizontal, vertical, ); diff --git a/render-wasm/src/render/ui/guides.rs b/render-wasm/src/render/ui/guides.rs index 07bc5a6f8a..8a9e3dcaa8 100644 --- a/render-wasm/src/render/ui/guides.rs +++ b/render-wasm/src/render/ui/guides.rs @@ -3,22 +3,43 @@ use skia_safe::{self as skia}; use crate::math::Rect; use crate::ui::{Guide, GuideKind}; -/// Renders the ruler guides overlay using the guides provided by the host -/// (ClojureScript) and stored in the render state. +/// Renders the ruler guides, clipped out of the ruler bars. pub fn render( canvas: &skia::Canvas, zoom: f32, dpr: f32, area: Rect, + ruler_width: f32, horizontal: &[Guide], vertical: &[Guide], ) { + // Horizontal guides: clip out the top strip (horizontal ruler) + canvas.save(); + canvas.clip_rect( + Rect::from_ltrb(area.left, area.top + ruler_width, area.right, area.bottom), + None, + false, + ); + for guide in horizontal { render_guide(canvas, zoom, dpr, area, *guide); } + + canvas.restore(); + + // Vertical guides: clip out the left strip (vertical ruler) + canvas.save(); + canvas.clip_rect( + Rect::from_ltrb(area.left + ruler_width, area.top, area.right, area.bottom), + None, + false, + ); + for guide in vertical { render_guide(canvas, zoom, dpr, area, *guide); } + + canvas.restore(); } pub fn render_guide(canvas: &skia::Canvas, zoom: f32, dpr: f32, area: Rect, guide: Guide) { From 67386a035881f7656fe36104376a218428711db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 25 Jun 2026 13:30:53 +0200 Subject: [PATCH 03/14] :bug: Fix missing tiles on the left (#10421) --- render-wasm/src/tiles.rs | 140 +++++++-------------------------------- 1 file changed, 25 insertions(+), 115 deletions(-) diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0d32b1ae1c..d00dc25bdf 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -312,102 +312,13 @@ impl TileHashMap { } const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12; -const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = VIEWPORT_DEFAULT_CAPACITY; - -/// Cached spiral of tile offsets for a given grid size. -/// -/// Offsets are centered at (0,0) and must be translated by the desired origin/center tile. -#[derive(Debug, Default)] -pub struct TileSpiral { - offsets: Vec, - columns: usize, - rows: usize, -} - -impl TileSpiral { - pub fn new() -> Self { - Self { - offsets: Vec::with_capacity(VIEWPORT_SPIRAL_DEFAULT_CAPACITY), - columns: 0, - rows: 0, - } - } - - #[inline] - pub fn iter(&self) -> std::slice::Iter<'_, Tile> { - self.offsets.iter() - } - - /// Ensure the spiral offsets match the given grid size. - /// - /// This regenerates offsets whenever the size changes (grow or shrink) so callers - /// don't accidentally reuse a spiral built for a previous viewport. - pub fn ensure(&mut self, columns: usize, rows: usize) { - if self.columns == columns && self.rows == rows { - return; - } - self.columns = columns; - self.rows = rows; - - let total = columns.saturating_mul(rows); - self.offsets.clear(); - self.offsets.reserve(total); - - if total == 0 { - return; - } - - // Generate tiles in spiral order from center (same algorithm as before). - let mut cx = 0; - let mut cy = 0; - - let ratio = (columns as f32 / rows as f32).ceil() as i32; - - let mut direction_current = 0; - let mut direction_total_x = ratio; - let mut direction_total_y = 1; - let mut direction = 0; - - self.offsets.push(Tile(cx, cy)); - while self.offsets.len() < total { - match direction { - 0 => cx += 1, - 1 => cy += 1, - 2 => cx -= 1, - 3 => cy -= 1, - _ => unreachable!("Invalid direction"), - } - - self.offsets.push(Tile(cx, cy)); - - direction_current += 1; - let direction_total = if direction % 2 == 0 { - direction_total_x - } else { - direction_total_y - }; - - if direction_current == direction_total { - if direction % 2 == 0 { - direction_total_x += 1; - } else { - direction_total_y += 1; - } - direction = (direction + 1) % 4; - direction_current = 0; - } - } - - self.offsets.reverse(); - } -} // This structure keeps the list of tiles that are in the pending list, the // ones that are going to be rendered. pub struct PendingTiles { pub list: Vec, - pub spiral: TileSpiral, - pub spiral_rect: TileRect, + pub tile_order: Vec<(i32, Tile)>, + pub tile_rect: TileRect, pub visible_cached: Vec, pub visible_uncached: Vec, pub interest_cached: Vec, @@ -418,8 +329,8 @@ impl PendingTiles { pub fn new() -> Self { Self { list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), - spiral: TileSpiral::new(), - spiral_rect: TileRect::empty(), + tile_order: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + tile_rect: TileRect::empty(), visible_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), @@ -435,22 +346,13 @@ impl PendingTiles { // path, and pre-rendering tiles outside the viewport is wasted // work that just gets evicted on the next pointer move. The ring // is repopulated naturally on gesture end / on idle rAFs. - let spiral_rect = if only_visible { + let tile_rect = if only_visible { &tile_viewbox.visible_rect } else { &tile_viewbox.interest_rect }; - self.spiral_rect = *spiral_rect; - - // We do not regenerate spiral if the spiral_rect - // doesn't change. The spiral_rect is based on the - // viewbox so, if the viewbox doesn't change - // the spiral should not change. - let columns = spiral_rect.columns(); - let rows = spiral_rect.rows(); - - self.spiral.ensure(columns as usize, rows as usize); + self.tile_rect = *tile_rect; // Partition tiles into 4 priority groups (highest priority = processed last due to pop()): // 1. visible + cached (fastest - just blit from cache) @@ -462,15 +364,25 @@ impl PendingTiles { self.interest_cached.clear(); self.interest_uncached.clear(); - // Compute the scheduling center explicitly (inclusive range). - // This avoids relying on `TileRect::center_x/center_y` semantics, which may be used - // elsewhere with different expectations. - let center_tile = Tile( - (spiral_rect.x1() + spiral_rect.x2()) / 2, - (spiral_rect.y1() + spiral_rect.y2()) / 2, - ); - for spiral_tile in self.spiral.iter() { - let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1); + // Enumerate every tile in `tile_rect`, ordered by distance from the + // rect center. + let center_x = (tile_rect.x1() + tile_rect.x2()) / 2; + let center_y = (tile_rect.y1() + tile_rect.y2()) / 2; + + self.tile_order.clear(); + + for tile in tile_rect.iter(true) { + let dx = tile.x() - center_x; + let dy = tile.y() - center_y; + self.tile_order.push((dx * dx + dy * dy, tile)); + } + + // Farthest first, since we use pop() to process the tiles + // in order of priority (closest first) + self.tile_order.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + + for (_, tile) in self.tile_order.iter() { + let tile = *tile; let is_visible = tile_viewbox.visible_rect.contains(&tile); let is_cached = surfaces.has_cached_tile_surface(tile); @@ -482,8 +394,6 @@ impl PendingTiles { } } - // Build final list with lowest priority first (they get popped last) - // Order: interest_uncached, interest_cached, visible_uncached, visible_cached self.list.extend(self.interest_uncached.iter()); self.list.extend(self.interest_cached.iter()); self.list.extend(self.visible_uncached.iter()); From 345affc6874c1669f00e1495a2c467b5a1844e19 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 25 Jun 2026 15:07:37 +0200 Subject: [PATCH 04/14] :bug: Fix premature WASM view-interaction end during pan (#10425) --- .../main/data/workspace/viewport_wasm.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 30 ++++++++++++------- render-wasm/src/main.rs | 7 +++++ render-wasm/src/render.rs | 13 ++++++-- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs index 4115589ab0..1c1caa801b 100644 --- a/frontend/src/app/main/data/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -27,4 +27,4 @@ (defn maybe-view-interaction-end! [state] (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) - (wasm.api/view-interaction-end!))) \ No newline at end of file + (wasm.api/finalize-view-interaction!))) \ No newline at end of file diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cc0814a6b2..f2a344fc25 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -357,12 +357,15 @@ (def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak". (def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end. (def ^:const FRAME_TYPE_FULL 2) ;; A frame was full. +(def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render ([] (internal-render 0)) ([timestamp] - (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp wasm/internal-frame-type)) + (internal-render timestamp wasm/internal-frame-type)) + ([timestamp flags] + (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp flags)) (when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) (request-render "frame-type-partial")))) @@ -1311,20 +1314,27 @@ (perf/end-measure "render-finish") (reset! view-interaction-active? false))) +(defn- view-gesture-active? + "True while a pointer-driven pan or zoom gesture is in progress." + [] + (let [local (get @st/state :workspace-local)] + (or (:panning local) (:zooming local)))) + +(defn finalize-view-interaction! + "Ends the view interaction and triggers a full-quality render." + [] + (view-interaction-end!) + (internal-render 0 0)) + (def render-finish (letfn [(do-render [] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly (when (initialized?) - (view-interaction-end!) - ;; Use async _render: visible tiles render synchronously - ;; (no yield), interest-area tiles render progressively - ;; via rAF. _set_view_end already rebuilt the tile - ;; index. For pan, most tiles are cached so the render - ;; completes in the first frame. For zoom, interest- - ;; area tiles (~3 tile margin) don't block the main - ;; thread. - (internal-render)))] + (if (view-gesture-active?) + ;; Pan/zoom pause: render without ending the interaction. + (internal-render 0 RENDER-FLAG-SYNC-TILES) + (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (defn set-view-box diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index bcdd9bf28a..0aa3fae930 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -123,6 +123,9 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { } } let is_partial = flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8; + if flags & RenderFlag::SyncTiles as u8 != 0 { + render_state.preserve_target_during_render = true; + } let frame_type = if is_partial && !render_state.preserve_target_during_render { state .continue_render_loop(timestamp) @@ -355,6 +358,10 @@ pub extern "C" fn set_view_end() -> Result<()> { // instead of re-drawing every visible tile from scratch. render_state.rebuild_tile_index(&state.shapes); } + // Avoid `reset_canvas` on the post-gesture render (pan at stable zoom). + if !render_state.options.is_profile_rebuild_tiles() { + render_state.preserve_target_during_render = true; + } performance::end_measure!("set_view_end"); }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index cc26d6de45..ee4ebe3bd0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -54,7 +54,8 @@ pub enum FrameType { pub enum RenderFlag { None = 0, Partial = 1, - Full = 2, + /// Rebuilds the tile index without leaving fast mode. + SyncTiles = 4, } #[derive(Debug)] @@ -2092,6 +2093,10 @@ impl RenderState { let preserve_target = self.preserve_target_during_render; self.preserve_target_during_render = false; + if preserve_target && self.options.is_fast_mode() { + self.rebuild_tile_index(tree); + } + if self.options.is_interactive_transform() { // Keep `Target` as the previous frame and overwrite only the tiles // that changed. This avoids clearing + redrawing an atlas backdrop @@ -3899,7 +3904,11 @@ impl RenderState { let mut all_tiles = HashSet::::new(); let ids = std::mem::take(&mut self.touched_ids); - self.preserve_target_during_render = !ids.is_empty(); + // Pan release sets `preserve_target` in `set_view_end`; don't reset it + // here when no shapes changed, or the next render clears the canvas. + if !ids.is_empty() { + self.preserve_target_during_render = true; + } for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { From 66719a14f52ee296e38e7ddfe67beeb5b9618e83 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 26 Jun 2026 09:42:22 +0200 Subject: [PATCH 05/14] :bug: Fix assets typography container is longer than others (#10406) * :bug: Fix assets typography container is longer than others * :recycle: Use new SCSS guidelines --- .../sidebar/assets/typographies.scss | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss index ebeac8d502..4c841ffbae 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss @@ -4,19 +4,21 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .assets-list { - padding: 0 0 0 deprecated.$s-4; + padding: 0 0 0 var(--sp-xs); } .drop-space { - height: deprecated.$s-12; + block-size: $sz-12; } .grid-placeholder { - height: deprecated.$s-2; - width: 100%; + block-size: px2rem(2); + inline-size: 100%; background-color: var(--color-accent-primary); } @@ -24,18 +26,19 @@ position: relative; display: flex; align-items: center; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$br-8; - background-color: var(--assets-item-background-color); + margin-block-end: var(--sp-xs); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + inline-size: var(--options-width); } .dragging { position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - border: deprecated.$s-2 solid var(--assets-item-border-color-drag); - border-radius: deprecated.$s-8; - background-color: var(--assets-item-background-color-drag); + inset-block-start: 0; + inset-inline-start: 0; + block-size: 100%; + inline-size: 100%; + border: $b-2 solid var(--color-accent-primary-muted); + border-radius: $br-8; + background-color: transparent; } From d16a2c93e0d119055c4b406666ba22e4c8986f55 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 26 Jun 2026 10:50:19 +0200 Subject: [PATCH 06/14] :bug: Fix long typography token name in tooltip in design tab (#10387) --- frontend/src/app/main/ui/ds/controls/shared/token_option.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss index 59f19b692c..e6e6e017be 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss @@ -86,4 +86,5 @@ .token-name-tooltip { color: var(--color-foreground-primary); + overflow-wrap: anywhere; } From 89f882ecdabdae0ff044c7411130bfaae4f637ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 26 Jun 2026 10:59:22 +0200 Subject: [PATCH 07/14] :bug: Fix viewer rendering on Firefox+NVIDIA setup (#10385) --- frontend/src/app/main/render_viewer_wasm.cljs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 9ae5d63994..45f7b83f73 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -8,6 +8,7 @@ "WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)." (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.render-wasm.api :as wasm.api] [app.render-wasm.wasm :as wasm] [app.util.dom :as dom] @@ -43,15 +44,31 @@ :dpr 1})) (defn- draw-bitmap! + "Blit the rendered OffscreenCanvas onto the visible 2D `canvas`. Firefox+NVIDIA + can't `drawImage` a managed OffscreenCanvas directly (yields transparent + pixels), but `drawImage` of an `ImageBitmap` works — so go through + `createImageBitmap` (GPU-side, correct orientation, no CPU readback)." [canvas os-canvas object-id vis-w vis-h finish] - (ts/raf - (fn [] - (let [ctx2d (.getContext canvas "2d")] - (.clearRect ctx2d 0 0 vis-w vis-h) - ;; Draw directly from OffscreenCanvas so it can be reused across passes. - (.drawImage ctx2d os-canvas 0 0 vis-w vis-h) - (dom/set-attribute! canvas "id" (str "screenshot-" object-id)) - (finish))))) + (-> (js/createImageBitmap os-canvas) + (p/then + (fn [bitmap] + (ts/raf + (fn [] + (let [ctx2d (.getContext canvas "2d")] + (.clearRect ctx2d 0 0 vis-w vis-h) + (.drawImage ctx2d bitmap 0 0 vis-w vis-h) + (.close bitmap) + (dom/set-attribute! canvas "id" (str "screenshot-" object-id)) + (finish)))))) + (p/catch + (fn [e] + (finish) + (ts/schedule + (fn [] + (ex/raise :type :wasm-error + :code :viewer-canvas-failed + :hint "Viewer canvas failed" + :cause e))))))) (defn- viewer-disable-wasm-ui-overlay! "Workspace WASM UI (rulers + rounded viewport frame) is composited in From 8e9fb919598287ed4df4791cf977c950fe81d87d Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 26 Jun 2026 11:38:51 +0200 Subject: [PATCH 08/14] :bug: Fix view mode is not persisted in color picker (#10369) --- frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index a304a2c27c..0de95be8ff 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -195,7 +195,7 @@ selected (deref selected*) layout (mf/deref refs/workspace-layout) - view-mode* (mf/use-state :grid) + view-mode* (h/use-persisted-state ::view-mode :grid) view-mode (deref view-mode*) file-id (mf/use-ctx ctx/current-file-id) From 10147b6abd83c7d1ed06d366221bdd0787c34f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 26 Jun 2026 11:53:11 +0200 Subject: [PATCH 09/14] :bug: Fix pixel grid and board pixel grid shown on top of rulers (#10430) * :bug: Fix pixel grid shown on top of rulers * :bug: Fix board pixel grid being rendered above rulers --- .../app/main/ui/workspace/viewport/frame_grid.cljs | 8 ++++++-- .../src/app/main/ui/workspace/viewport/rulers.cljs | 13 +++++++++++++ .../src/app/main/ui/workspace/viewport/widgets.cljs | 6 +++++- .../src/app/main/ui/workspace/viewport_wasm.cljs | 7 +++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs index 7c77a4351f..53b9eefe56 100644 --- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs @@ -15,6 +15,7 @@ [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [app.main.refs :as refs] + [app.main.ui.workspace.viewport.rulers :as rulers] [rumext.v2 :as mf])) (mf/defc square-grid* [{:keys [frame zoom grid]}] @@ -165,12 +166,15 @@ (mf/defc frame-grid* {::mf/wrap [mf/memo]} - [{:keys [zoom transform selected focus]}] + [{:keys [zoom transform selected focus vbox clip-rulers] :or {clip-rulers false}}] (let [frames (->> (mf/deref refs/workspace-frames) (filter has-grid?)) transforming (when (some? transform) selected)] - [:g.grid-display {:style {:pointer-events "none"}} + [:g.grid-display {:style {:pointer-events "none"} + :clip-path (when clip-rulers "url(#clip-frame-grid)")} + (when clip-rulers + [:> rulers/rulers-clip-path* {:id "clip-frame-grid" :vbox vbox :zoom zoom}]) (for [frame frames] (when (and #_(not (is-transform? frame)) (not (ctst/rotated-frame? frame)) diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index 89c71a8afc..4a8c82cd61 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -35,6 +35,19 @@ ;; RULERS ;; ---------------- +(mf/defc rulers-clip-path* + "Defines a clip path (referenced by `id`) that excludes the ruler bars from the + given `vbox`. Used to keep SVG overlays from painting over the rulers that are + drawn by the wasm render engine on the canvas." + [{:keys [id vbox zoom]}] + (let [ruler-size (/ ruler-area-size zoom)] + [:defs + [:clipPath {:id id} + [:rect {:x (+ (:x vbox) ruler-size) + :y (+ (:y vbox) ruler-size) + :width (max 0 (- (:width vbox) ruler-size)) + :height (max 0 (- (:height vbox) ruler-size))}]]])) + (defn- calculate-step-size [zoom] (cond diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 744f9abf3f..5521d5bbd6 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -23,6 +23,7 @@ [app.main.ui.context :as ctx] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.viewport.rulers :as rulers] [app.main.ui.workspace.viewport.utils :as vwu] [app.util.debug :as dbg] [app.util.dom :as dom] @@ -32,7 +33,7 @@ [rumext.v2 :as mf])) (mf/defc pixel-grid* - [{:keys [vbox zoom]}] + [{:keys [vbox zoom clip-rulers] :or {clip-rulers false}}] (let [page (mf/deref refs/workspace-page) custom-color (:pixel-grid-color page) custom-alpha (:pixel-grid-opacity page) @@ -57,11 +58,14 @@ :stroke stroke :stroke-opacity opacity :stroke-width (str (/ 1 zoom))}}]]] + (when clip-rulers + [:> rulers/rulers-clip-path* {:id "clip-pixel-grid" :vbox vbox :zoom zoom}]) [:rect {:x (:x vbox) :y (:y vbox) :width (:width vbox) :height (:height vbox) :fill (str "url(#pixel-grid)") + :clip-path (when clip-rulers "url(#clip-pixel-grid)") :style {:pointer-events "none"}}]])) (mf/defc cursor-tooltip* diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 61f5025f81..285c43e223 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -851,11 +851,14 @@ {:zoom zoom :selected selected :transform transform - :focus focus}]) + :focus focus + :vbox vbox + :clip-rulers show-rulers?}]) (when show-pixel-grid? [:> widgets/pixel-grid* {:vbox vbox - :zoom zoom}]) + :zoom zoom + :clip-rulers show-rulers?}]) (when show-snap-points? [:> snap-points/snap-points* From 6a79383082e80b914db4a4cb80c764fe09aab3cf Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 26 Jun 2026 14:10:41 +0200 Subject: [PATCH 10/14] :bug: Blur info doesn't show on inspect in certain shapes (#10427) * :bug: Blur info doesn't show on inspect in certain shapes * :tada: Add test --- .../playwright/ui/specs/inspect-tab.spec.js | 47 +++++++++++++++++ .../src/app/main/ui/inspect/attributes.cljs | 28 +++++------ .../app/main/ui/inspect/attributes/blur.cljs | 50 +++++++++++++------ .../main/ui/inspect/attributes/geometry.cljs | 2 +- .../main/ui/inspect/attributes/layout.cljs | 2 +- .../ui/inspect/attributes/layout_element.cljs | 2 +- .../main/ui/inspect/attributes/shadow.cljs | 3 +- .../app/main/ui/inspect/attributes/svg.cljs | 2 +- .../app/main/ui/inspect/attributes/text.cljs | 2 +- frontend/src/app/main/ui/inspect/styles.cljs | 3 +- .../main/ui/inspect/styles/panels/blur.cljs | 24 +++++---- .../app/util/code_gen/style_css_formats.cljs | 2 +- 12 files changed, 118 insertions(+), 49 deletions(-) diff --git a/frontend/playwright/ui/specs/inspect-tab.spec.js b/frontend/playwright/ui/specs/inspect-tab.spec.js index 064ae644b2..1b1e6362c7 100644 --- a/frontend/playwright/ui/specs/inspect-tab.spec.js +++ b/frontend/playwright/ui/specs/inspect-tab.spec.js @@ -284,6 +284,53 @@ test.describe("Inspect tab - Styles", () => { await setupFile(workspacePage); await selectLayer(workspacePage, shapeToLayerName.blur); + + await openInspectTab(workspacePage); + + const panel = await getPanelByTitle(workspacePage, "Blur"); + await expect(panel).toBeVisible(); + + const propertyRow = panel.getByTestId("property-row"); + const propertyRowCount = await propertyRow.count(); + + expect(propertyRowCount).toBeGreaterThanOrEqual(1); + }); + + test("Shape - Blur on not svg compatible shape", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await setupFile(workspacePage); + + await selectLayer(workspacePage, shapeToLayerName.blur); + + await workspacePage.page.getByTestId("add-stroke").click(); + await workspacePage.page.getByTestId("stroke.alignment").click(); + await workspacePage.page.getByRole("option", { name: "Center" }).click(); + + await openInspectTab(workspacePage); + + const panel = await getPanelByTitle(workspacePage, "Blur"); + await expect(panel).toBeVisible(); + + const propertyRow = panel.getByTestId("property-row"); + const propertyRowCount = await propertyRow.count(); + + expect(propertyRowCount).toBeGreaterThanOrEqual(1); + }); + + test("Shape - Background Blur on not svg compatible shape", async ({ + page, + }) => { + const workspacePage = new WasmWorkspacePage(page); + await setupFile(workspacePage); + + await selectLayer(workspacePage, shapeToLayerName.blur); + + await workspacePage.page.getByRole('combobox', { name: 'Blur type select' }).click(); + await workspacePage.page.getByRole('option', { name: 'Background blur' }).click(); + await workspacePage.page.getByTestId("add-stroke").click(); + await workspacePage.page.getByTestId("stroke.alignment").click(); + await workspacePage.page.getByRole("option", { name: "Center" }).click(); + await openInspectTab(workspacePage); const panel = await getPanelByTitle(workspacePage, "Blur"); diff --git a/frontend/src/app/main/ui/inspect/attributes.cljs b/frontend/src/app/main/ui/inspect/attributes.cljs index 0223bb8d60..7535709328 100644 --- a/frontend/src/app/main/ui/inspect/attributes.cljs +++ b/frontend/src/app/main/ui/inspect/attributes.cljs @@ -12,15 +12,15 @@ [app.common.types.components-list :as ctkl] [app.main.ui.hooks :as hooks] [app.main.ui.inspect.annotation :refer [annotation*]] - [app.main.ui.inspect.attributes.blur :refer [blur-panel]] + [app.main.ui.inspect.attributes.blur :refer [blur-panel*]] [app.main.ui.inspect.attributes.fill :refer [fill-panel*]] - [app.main.ui.inspect.attributes.geometry :refer [geometry-panel]] - [app.main.ui.inspect.attributes.layout :refer [layout-panel]] - [app.main.ui.inspect.attributes.layout-element :refer [layout-element-panel]] - [app.main.ui.inspect.attributes.shadow :refer [shadow-panel]] + [app.main.ui.inspect.attributes.geometry :refer [geometry-panel*]] + [app.main.ui.inspect.attributes.layout :refer [layout-panel*]] + [app.main.ui.inspect.attributes.layout-element :refer [layout-element-panel*]] + [app.main.ui.inspect.attributes.shadow :refer [shadow-panel*]] [app.main.ui.inspect.attributes.stroke :refer [stroke-panel*]] - [app.main.ui.inspect.attributes.svg :refer [svg-panel]] - [app.main.ui.inspect.attributes.text :refer [text-panel]] + [app.main.ui.inspect.attributes.svg :refer [svg-panel*]] + [app.main.ui.inspect.attributes.text :refer [text-panel*]] [app.main.ui.inspect.attributes.variant :refer [variant-panel*]] [app.main.ui.inspect.attributes.visibility :refer [visibility-panel*]] [app.main.ui.inspect.exports :refer [exports]] @@ -61,16 +61,16 @@ :workspace-element-options (= from :workspace))} (for [[idx option] (map-indexed vector options)] [:> (case option - :geometry geometry-panel - :layout layout-panel - :layout-element layout-element-panel + :geometry geometry-panel* + :layout layout-panel* + :layout-element layout-element-panel* :fill fill-panel* :stroke stroke-panel* - :shadow shadow-panel - :blur blur-panel + :shadow shadow-panel* + :blur blur-panel* :visibility visibility-panel* - :text text-panel - :svg svg-panel + :text text-panel* + :svg svg-panel* :variant variant-panel*) {:key idx :shapes shapes diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.cljs b/frontend/src/app/main/ui/inspect/attributes/blur.cljs index 821df98814..ba50e6be32 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/blur.cljs @@ -13,6 +13,7 @@ [app.main.ui.components.copy-button :refer [copy-button*]] [app.main.ui.components.title-bar :refer [inspect-title-bar*]] [app.util.code-gen.style-css :as css] + [app.util.code-gen.style-css-formats :refer [format-blur]] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -20,8 +21,8 @@ (or (:blur shape) (:background-blur shape))) -(mf/defc blur-panel - [{:keys [objects shapes]}] +(mf/defc blur-panel* + [{:keys [shapes]}] (let [render-wasm? (features/use-feature "render-wasm/v1") bg-blur? (and render-wasm? (contains? cf/flags :background-blur)) @@ -36,34 +37,51 @@ :class (stl/css :title-wrapper) :title-class (stl/css :blur-attr-title)} (when (= (count shapes) 1) - (let [background-blur (:background-blur (first shapes)) - layer-blur (:blur (first shapes))] + (let [layer-blur (:blur (first shapes)) + blur-property :filter + blue-value-raw (get-in (first shapes) [:blur :value]) + blur-property-value (css/format-css-property [blur-property blue-value-raw] {}) + + background-blur (:background-blur (first shapes)) + background-blur-property :backdrop-filter + background-blur-value-raw (get-in (first shapes) [:background-blur :value]) + background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})] (when background-blur - [:> copy-button* {:data (css/get-css-property objects (first shapes) :backdrop-filter) + [:> copy-button* {:data (dm/str background-blur-property-value) :class (stl/css :copy-btn-title)}]) (when layer-blur - [:> copy-button* {:data (css/get-css-property objects (first shapes) :filter) + [:> copy-button* {:data (dm/str blur-property-value) :class (stl/css :copy-btn-title)}])))] [:div {:class (stl/css :attributes-content)} (for [shape shapes] - (let [background-blur (:background-blur (first shapes)) - layer-blur (:blur (first shapes))] - [:div {:class (stl/css :blur-row) - :key (dm/str "block-" (:id shape) "-blur")} + (let [layer-blur (:blur (first shapes)) + blur-property :filter + blue-value-raw (get-in (first shapes) [:blur :value]) + blur-property-value (css/format-css-property [blur-property blue-value-raw] {}) + blur-value-detail (format-blur blue-value-raw) + + background-blur (:background-blur (first shapes)) + background-blur-property :backdrop-filter + background-blur-value-raw (get-in (first shapes) [:background-blur :value]) + background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {}) + background-blur-value-detail (format-blur background-blur-value-raw)] + [:* (when background-blur - [:div {:key (dm/str "block-" (:id shape) "-background-blur")} + [:div {:class (stl/css :blur-row) + :key (dm/str "block-" (:id shape) "-background-blur")} [:div {:class (stl/css :global/attr-label)} "Backdrop Filter"] [:div {:class (stl/css :global/attr-value)} - [:> copy-button* {:data (css/get-css-property objects shape :backdrop-filter)} + [:> copy-button* {:data (dm/str background-blur-property-value)} [:div {:class (stl/css :button-children)} - (css/get-css-value objects shape :backdrop-filter)]]]]) + (dm/str background-blur-value-detail)]]]]) (when layer-blur - [:div {:key (dm/str "block-" (:id shape) "-layer-blur")} + [:div {:class (stl/css :blur-row) + :key (dm/str "block-" (:id shape) "-layer-blur")} [:div {:class (stl/css :global/attr-label)} "Filter"] [:div {:class (stl/css :global/attr-value)} - [:> copy-button* {:data (css/get-css-property objects shape :filter)} + [:> copy-button* {:data (dm/str blur-property-value)} [:div {:class (stl/css :button-children)} - (css/get-css-value objects shape :filter)]]]])]))]]))) + (dm/str blur-value-detail)]]]])]))]]))) diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs index 03abd63f59..dbfa79a52c 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs @@ -39,7 +39,7 @@ [:div {:class (stl/css :button-children)} value]]]])))]) -(mf/defc geometry-panel +(mf/defc geometry-panel* [{:keys [objects shapes]}] [:div {:class (stl/css :attributes-block)} [:> inspect-title-bar* diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.cljs b/frontend/src/app/main/ui/inspect/attributes/layout.cljs index b82102de43..8c2d227f6f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout.cljs @@ -49,7 +49,7 @@ [:> copy-button* {:data (css/get-css-property objects shape property)} [:div {:class (stl/css :button-children)} value]]]])))) -(mf/defc layout-panel +(mf/defc layout-panel* [{:keys [objects shapes]}] (let [shapes (->> shapes (filter ctl/any-layout?))] diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs index 87b3cb1d0c..11b904d1a7 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs @@ -44,7 +44,7 @@ [:> copy-button* {:data (css/get-css-property objects shape property)} [:div {:class (stl/css :button-children)} value]]]])))) -(mf/defc layout-element-panel +(mf/defc layout-element-panel* [{:keys [objects shapes]}] (let [shapes (->> shapes (filter #(ctl/any-layout-immediate-child? objects %))) only-flex? (every? #(ctl/flex-layout-immediate-child? objects %) shapes) diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs index 140f9de5c7..0f092d9b6c 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs @@ -56,7 +56,8 @@ :copy-data (copy-color-data (:color shadow) color-format*) :on-change-format on-change-format}]])) -(mf/defc shadow-panel [{:keys [shapes]}] +(mf/defc shadow-panel* + [{:keys [shapes]}] (let [shapes (->> shapes (filter has-shadow?))] (when (and (seq shapes) (> (count shapes) 0)) diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.cljs b/frontend/src/app/main/ui/inspect/attributes/svg.cljs index 11222a5c47..5ccb3dacee 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/svg.cljs @@ -47,7 +47,7 @@ (for [[attr-key attr-value] (:svg-attrs shape)] [:& svg-attr {:attr attr-key :value attr-value :key (str/join "svg-block-key-" (d/name attr-key))}])]) -(mf/defc svg-panel +(mf/defc svg-panel* [{:keys [shapes]}] (let [shape (first shapes)] (when (seq (:svg-attrs shape)) diff --git a/frontend/src/app/main/ui/inspect/attributes/text.cljs b/frontend/src/app/main/ui/inspect/attributes/text.cljs index 02462229ad..a8565fde26 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/text.cljs @@ -151,7 +151,7 @@ :style full-style :text text}]))) -(mf/defc text-panel +(mf/defc text-panel* [{:keys [shapes]}] (when-let [shapes (seq (filter has-text? shapes))] [:div {:class (stl/css :attributes-block)} diff --git a/frontend/src/app/main/ui/inspect/styles.cljs b/frontend/src/app/main/ui/inspect/styles.cljs index 7b8238e40c..df61a66778 100644 --- a/frontend/src/app/main/ui/inspect/styles.cljs +++ b/frontend/src/app/main/ui/inspect/styles.cljs @@ -246,8 +246,7 @@ (let [shapes (->> shapes (filter has-blur?))] (when (seq shapes) [:> style-box* {:panel :blur} - [:> blur-panel* {:shapes shapes - :objects objects}]])) + [:> blur-panel* {:shapes shapes}]])) ;; TEXT PANEL :text (let [shapes (filter has-text? shapes)] diff --git a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs index b67c466d88..6f089cf256 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs @@ -11,31 +11,35 @@ [app.main.ui.inspect.attributes.common :as cmm] [app.main.ui.inspect.styles.rows.properties-row :refer [properties-row*]] [app.util.code-gen.style-css :as css] + [app.util.code-gen.style-css-formats :refer [format-blur]] [rumext.v2 :as mf])) (mf/defc blur-panel* - [{:keys [shapes objects]}] + [{:keys [shapes]}] [:div {:class (stl/css :blur-panel)} (for [shape shapes] [:div {:key (:id shape) :class (stl/css :blur-shape)} (let [blur-property :filter - blur-value (css/get-css-value objects shape blur-property) - background-blur-property :backdrop-filter + blue-value-raw (get-in shape [:blur :value]) + blur-value-detail (format-blur blue-value-raw) blur-property-name (cmm/get-css-rule-humanized blur-property) - blur-property-value (css/get-css-property objects shape blur-property) - background-blur-value (css/get-css-value objects shape background-blur-property) + blur-property-value (css/format-css-property [blur-property blue-value-raw] {}) + + background-blur-property :backdrop-filter + background-blur-value-raw (get-in shape [:background-blur :value]) + background-blur-value-detail (format-blur background-blur-value-raw) background-blur-property-name (cmm/get-css-rule-humanized background-blur-property) - background-blur-property-value (css/get-css-property objects shape background-blur-property)] + background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})] [:div - (when blur-property-value + (when blue-value-raw [:> properties-row* {:key (dm/str "blur-property-" blur-property) :term blur-property-name - :detail (dm/str blur-value) + :detail blur-value-detail :property blur-property-value :copiable true}]) - (when background-blur-property-value + (when background-blur-value-raw [:> properties-row* {:key (dm/str "blur-property-" background-blur-property) :term background-blur-property-name - :detail (dm/str background-blur-value) + :detail background-blur-value-detail :property background-blur-property-value :copiable true}])])])]) diff --git a/frontend/src/app/util/code_gen/style_css_formats.cljs b/frontend/src/app/util/code_gen/style_css_formats.cljs index 9a650f7528..85e305fa4d 100644 --- a/frontend/src/app/util/code_gen/style_css_formats.cljs +++ b/frontend/src/app/util/code_gen/style_css_formats.cljs @@ -174,7 +174,7 @@ (map #(format-shadow->css % options)) (str/join ", "))) -(defn- format-blur +(defn format-blur [value] (dm/fmt "blur(%)" (fmt/format-pixels value))) From 44e39a1008f4aa46d5cca44e7e7f9271653412aa Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 26 Jun 2026 14:24:44 +0200 Subject: [PATCH 11/14] :bug: Sync WASM viewport when locating board in grid layout editor (#10443) --- .../src/app/main/data/workspace/grid_layout/editor.cljs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs index f73502a2e4..07fb2c093b 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs @@ -10,6 +10,7 @@ [app.common.geom.rect :as grc] [app.common.types.shape.layout :as ctl] [app.main.data.helpers :as dsh] + [app.main.data.workspace.viewport-wasm :as dwvw] [potok.v2.core :as ptk])) (defn hover-grid-cell @@ -104,7 +105,11 @@ y (+ y (/ height 2) (- (/ (:height vport) 2 zoom))) srect (grc/make-rect x y width height)] (-> local - (update :vbox merge (select-keys srect [:x :y :x1 :x2 :y1 :y2]))))))))))) + (update :vbox merge (select-keys srect [:x :y :x1 :x2 :y1 :y2]))))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn select-track-cells [grid-id type index] From 736a22ab1dd19406c791010c31bd6e766cd0e8c9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 14:19:11 +0200 Subject: [PATCH 12/14] :books: Add paren-repair script for automatic parentheses repair --- .serena/memories/backend/core.md | 4 + .serena/memories/critical-info.md | 6 + .serena/memories/frontend/core.md | 5 + .../frontend/handling-errors-and-debugging.md | 6 + .serena/memories/tools/paren-repair.md | 29 ++ tools/paren-repair.bb | 361 ++++++++++++++++++ 6 files changed, 411 insertions(+) create mode 100644 .serena/memories/tools/paren-repair.md create mode 100755 tools/paren-repair.bb diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index b7a9199965..34a272dedc 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -92,6 +92,10 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Linting:** `pnpm run lint` from the repository root. * **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs. +**Before linting:** if delimiter errors are suspected (after LLM edits), run +`tools/paren-repair.bb` on the affected files first. Delimiter errors produce +misleading linter/compiler output. See `mem:tools/paren-repair`. + ## Testing IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 496bcf6f2e..c6f57cb8bd 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -14,6 +14,9 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. *After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`). +- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, + fix them with `tools/paren-repair.bb` BEFORE running lint/format checks. + See `mem:tools/paren-repair` for usage. - Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra. # Project modules @@ -39,6 +42,9 @@ module. You can read it from `mem:/core` When working on devenv startup, compose layout, instance config (`defaults.env`), tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s `*-devenv` commands, read `mem:devenv/core`. +- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval), + `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and + `taiga.py` / `gh.py` (issue management helpers). - `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. - `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 7e0638ece2..7edf80bbe1 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -26,6 +26,11 @@ From `frontend/`: - Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. - Translation formatting after i18n edits: `pnpm run translations`. +**Before linting:** if delimiter errors are suspected (after LLM edits, or +lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the +affected files first. Delimiter errors produce misleading linter output. +See `mem:tools/paren-repair`. + ## Focused memory routing UI and packages: diff --git a/.serena/memories/frontend/handling-errors-and-debugging.md b/.serena/memories/frontend/handling-errors-and-debugging.md index 29c7929112..ede8c1581b 100644 --- a/.serena/memories/frontend/handling-errors-and-debugging.md +++ b/.serena/memories/frontend/handling-errors-and-debugging.md @@ -10,6 +10,12 @@ You have access to two tools for finding errors in Clojure source code (which yo The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second tool can often find the exact location of such errors. +When delimiter errors are detected (typically from lint or compiler output), +fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses` +MCP tool can also pinpoint the error location when available, but it is not +required — standard build errors are usually enough. +See `mem:tools/paren-repair`. + ## Runtime patching with `set!` Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. diff --git a/.serena/memories/tools/paren-repair.md b/.serena/memories/tools/paren-repair.md new file mode 100644 index 0000000000..e3817ca839 --- /dev/null +++ b/.serena/memories/tools/paren-repair.md @@ -0,0 +1,29 @@ +# Paren-Repair + +`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in +Clojure/ClojureScript files, then reformats them with cljfmt. + +## When to use + +- After LLM edits introduce broken delimiters — proactively run it on files + you just touched. +- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax + errors mentioning mismatched/unclosed delimiters, reader errors, or + unexpected EOF. +- Before running lint/format checks — delimiter errors make linter output + misleading. Fix them first, then lint. + +## How to use + +```bash +# File mode (in-place fix + format) +bb tools/paren-repair.bb path/to/file.clj + +# Pipe mode (stdin → fixed code to stdout) +echo '(def x 1' | bb tools/paren-repair.bb + +# Help +bb tools/paren-repair.bb --help +``` + +`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves. diff --git a/tools/paren-repair.bb b/tools/paren-repair.bb new file mode 100755 index 0000000000..aba0ee26ac --- /dev/null +++ b/tools/paren-repair.bb @@ -0,0 +1,361 @@ +#!/usr/bin/env bb + +;; ── Dependencies (resolved once, cached forever by Babashka) ── +(babashka.deps/add-deps + '{:deps {dev.weavejester/cljfmt {:mvn/version "0.15.5"} + parinferish/parinferish {:mvn/version "0.8.0"}}}) + +(ns paren-repair + "Standalone CLI tool for fixing delimiter errors and formatting Clojure files. + + Single-file consolidation of: + clojure-mcp-light.delimiter-repair (detection + repair engine) + clojure-mcp-light.hook (file detection + combine repair+format) + clojure-mcp-light.paren-repair (CLI / main entry point) + + Stripped: stats logging, timbre, tools.cli, apply-patch, tmp sessions. + Includes a fix for the process-stdin destructuring bug in the original." + + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [cljfmt.core :as cljfmt] + [cljfmt.main] + [clojure.java.io :as io] + [clojure.java.shell :as shell] + [clojure.string :as string] + [edamame.core :as e] + [parinferish.core :as parinferish])) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Section 1: Delimiter Detection & Repair +;; (from delimiter_repair.clj — stats calls removed) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(def ^:dynamic *signal-on-bad-parse* + "When true, non-delimiter parse errors still trigger parinfer as a safety net. + Running parinfer on valid code is generally benign." + true) + +(defn delimiter-error? + "Returns true if the string has a delimiter error specifically. + Checks that it's an :edamame/error with :edamame/opened-delimiter info. + Uses :all true to enable all standard Clojure reader features: + function literals, regex, quotes, syntax-quote, deref, var, etc. + Also enables :read-cond :allow to support reader conditionals. + Handles unknown data readers gracefully with a default reader fn." + [s] + (try + (e/parse-string-all s {:all true + :features #{:bb :clj :cljs :cljr :default} + :read-cond :allow + :readers (fn [_tag] (fn [data] data)) + :auto-resolve name}) + false ;; No error = no delimiter error + (catch clojure.lang.ExceptionInfo ex + (let [data (ex-data ex)] + (and (= :edamame/error (:type data)) + (contains? data :edamame/opened-delimiter)))) + (catch Exception _ + ;; Non-edamame parse error — run parinfer as a safety net + ;; (parinfer on valid code is generally benign) + *signal-on-bad-parse*))) + +(defn actual-delimiter-error? + "Like delimiter-error? but never falls back to parinfer on unknown parse errors." + [s] + (binding [*signal-on-bad-parse* false] + (delimiter-error? s))) + +(defn parinferish-repair + "Attempts to repair delimiter errors using parinferish (pure Clojure). + Returns a map with :success, :text, and :error." + [s] + (try + (let [repaired (parinferish/flatten + (parinferish/parse s {:mode :indent}))] + {:success true + :text repaired + :error nil}) + (catch Exception e + {:success false + :error (.getMessage e)}))) + +(def parinfer-rust-available? + "Check if parinfer-rust binary is available on PATH. Result is memoized." + (memoize + (fn [] + (try + (let [result (shell/sh "which" "parinfer-rust")] + (zero? (:exit result))) + (catch Exception _ + false))))) + +(defn parinfer-repair + "Attempts to repair delimiter errors using parinfer-rust (external binary). + Returns a map with :success, :text, and :error. + Uses JSON output format from parinfer-rust." + [s] + (let [result (shell/sh "parinfer-rust" + "--mode" "indent" + "--language" "clojure" + "--output-format" "json" + :in s) + exit-code (:exit result)] + (if (zero? exit-code) + (try + (json/parse-string (:out result) true) + (catch Exception _ + {:success false})) + {:success false}))) + +(defn repair-delimiters + "Unified delimiter repair: prefers parinfer-rust when available, + falls back to parinferish (pure Clojure). + Returns a map with :success, :text, and :error." + [s] + (if (parinfer-rust-available?) + (parinfer-repair s) + (parinferish-repair s))) + +(defn fix-delimiters + "Takes a Clojure string and attempts to fix delimiter errors. + Returns the repaired string if successful, or nil if unfixable. + If no delimiter errors exist, returns the original string unchanged." + [s] + (if (delimiter-error? s) + (let [{:keys [text success]} (repair-delimiters s)] + (when (and success text (not (delimiter-error? text))) + text)) + s)) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Section 2: File Processing +;; (from hook.clj — stripped of stats, timbre, backup/restore, hook dispatch) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(def ^:dynamic *enable-cljfmt* + "When true, files are reformatted with cljfmt after delimiter repair." + false) + +(defn- babashka-shebang? + "Checks if a file starts with a Babashka shebang line." + [file-path] + (when (fs/exists? file-path) + (try + (with-open [r (io/reader file-path)] + (let [line (-> r line-seq first)] + (and line + (re-matches #"^#!/[^\s]+/(bb|env\s{1,3}bb)(\s.*)?$" line)))) + (catch Exception _ false)))) + +(defn clojure-file? + "Checks if a file path has a Clojure-related extension or Babashka shebang. + Supported extensions: .clj .cljs .cljc .cljd .bb .edn .lpy + Also detects files with a Babashka shebang (#!/.../bb)." + [file-path] + (when file-path + (let [lower-path (string/lower-case file-path)] + (or (string/ends-with? lower-path ".clj") + (string/ends-with? lower-path ".cljs") + (string/ends-with? lower-path ".cljc") + (string/ends-with? lower-path ".cljd") + (string/ends-with? lower-path ".bb") + (string/ends-with? lower-path ".lpy") + (string/ends-with? lower-path ".edn") + (babashka-shebang? file-path))))) + +(defn run-cljfmt + "Check if file needs formatting (via cljfmt.core), then reformat in-place + (via cljfmt.main to respect user cljfmt config). Returns true if file was + reformatted, false otherwise." + [file-path] + (when *enable-cljfmt* + (try + (let [original (slurp file-path :encoding "UTF-8") + formatted (cljfmt/reformat-string original)] + (if (not= original formatted) + (do + (cljfmt.main/-main "fix" file-path) + true) + false)) + (catch Exception _ + false)))) + +(defn fix-and-format-file! + "Core logic for fixing delimiters and (optionally) formatting a Clojure file + in-place. Returns a map with :success, :delimiter-fixed, :formatted, and + :message." + [file-path enable-cljfmt] + (try + (let [file-content (slurp file-path :encoding "UTF-8") + has-delimiter-error? (delimiter-error? file-content)] + (if has-delimiter-error? + ;; Has delimiter error — try to fix + (if-let [fixed-content (fix-delimiters file-content)] + (do + (spit file-path fixed-content :encoding "UTF-8") + (let [formatted? (binding [*enable-cljfmt* enable-cljfmt] + (run-cljfmt file-path))] + {:success true + :delimiter-fixed true + :formatted (boolean formatted?) + :message "Delimiter errors fixed and formatted"})) + {:success false + :delimiter-fixed false + :formatted false + :message "Could not fix delimiter errors"}) + ;; No delimiter error — just format if enabled + (let [formatted? (binding [*enable-cljfmt* enable-cljfmt] + (run-cljfmt file-path))] + {:success true + :delimiter-fixed false + :formatted (boolean formatted?) + :message (if formatted? "Formatted" "No changes needed")}))) + (catch Exception e + {:success false + :delimiter-fixed false + :formatted false + :message (str "Error: " (.getMessage e))}))) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Section 3: CLI +;; (from paren_repair.clj — with process-stdin bug fix, no timbre) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(defn has-stdin-data? + "Check if stdin has data available (not a TTY). + Returns true if stdin is ready to be read (e.g., piped input or heredoc)." + [] + (try + (.ready *in*) + (catch Exception _ false))) + +(defn process-stdin + "Process code from stdin: fix delimiters and format. + Outputs result to stdout. + Returns a map with :success and :changed." + [] + (let [input (slurp *in*) + fixed (fix-delimiters input)] + (if fixed + ;; fix-delimiters succeeded (or no errors) — format and print + (let [formatted (try + (cljfmt/reformat-string fixed) + (catch Exception _ + fixed)) + changed? (not= input formatted)] + (print formatted) + (flush) + {:success true + :changed changed?}) + ;; fix-delimiters returned nil (unfixable) + (do + (binding [*out* *err*] + (println "Error: Could not fix delimiter errors")) + {:success false + :changed false})))) + +(defn process-file + "Process a single file: fix delimiters and format in-place. + Returns a map with :success, :file-path, :message, :delimiter-fixed, + and :formatted." + [file-path] + (cond + (not (fs/exists? file-path)) + {:success false + :file-path file-path + :message "File does not exist" + :delimiter-fixed false + :formatted false} + + (not (clojure-file? file-path)) + {:success false + :file-path file-path + :message "Not a Clojure file (skipping)" + :delimiter-fixed false + :formatted false} + + :else + (assoc (fix-and-format-file! file-path true) + :file-path file-path))) + +(defn show-help [] + (println "Usage: paren-repair [FILE ...]") + (println " echo CODE | paren-repair") + (println " paren-repair <<'EOF' ... EOF") + (println) + (println "Fix delimiter errors and format Clojure code.") + (println) + (println "When no files are provided, reads from stdin and writes to stdout.") + (println "If no changes are needed, echoes the input unchanged.") + (println) + (println "Options:") + (println " -h, --help Show this help message")) + +(defn -main [& args] + (let [show-help? (some #{"--help" "-h"} args) + file-args (remove #{"--help" "-h"} args)] + + (cond + ;; Help requested + show-help? + (do + (show-help) + (System/exit 0)) + + ;; No file args — check for stdin + (empty? file-args) + (if (has-stdin-data?) + ;; Stdin mode: read, process, output to stdout + (let [result (process-stdin)] + (System/exit (if (:success result) 0 1))) + ;; No stdin and no files — show help + (do + (show-help) + (System/exit 1))) + + ;; File mode + :else + (try + (let [results (doall (map process-file file-args)) + successes (filter :success results) + failures (filter (complement :success) results) + success-count (count successes) + failure-count (count failures)] + + ;; Print results + (println) + (println "paren-repair Results") + (println "========================") + (println) + + (doseq [{:keys [file-path message delimiter-fixed formatted]} results] + (let [tags (when (or delimiter-fixed formatted) + (str " [" + (string/join ", " + (filter some? + [(when delimiter-fixed "delimiter-fixed") + (when formatted "formatted")])) + "]"))] + (println (str " " file-path ": " message tags)))) + + (println) + (println "Summary:") + (println " Success:" success-count) + (println " Failed: " failure-count) + (println) + + (if (zero? failure-count) + (System/exit 0) + (System/exit 1))) + (catch Exception e + (binding [*out* *err*] + (println "Fatal error:" (.getMessage e))) + (System/exit 1)))))) + +;; ═══════════════════════════════════════════════════════════════════════════════ +;; Entry point — only run -main when executed directly (not loaded as lib) +;; ═══════════════════════════════════════════════════════════════════════════════ + +(when (= *file* (System/getProperty "babashka.file")) + (apply -main *command-line-args*)) From 925dca35ab6d48d2fdd54c7b8b666580966a415d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 14:28:49 +0200 Subject: [PATCH 13/14] :books: Update common testing doc on .serena --- .serena/memories/common/core.md | 2 +- .../common/{test-setup.md => testing.md} | 35 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) rename .serena/memories/common/{test-setup.md => testing.md} (60%) diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index a74939d58c..474fb8d87e 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -48,7 +48,7 @@ Components, variants, and debugging: Text and tests: - Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. -- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/test-setup`. +- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`. ## Areas without focused memories diff --git a/.serena/memories/common/test-setup.md b/.serena/memories/common/testing.md similarity index 60% rename from .serena/memories/common/test-setup.md rename to .serena/memories/common/testing.md index 32f87710e9..bfd126d26f 100644 --- a/.serena/memories/common/test-setup.md +++ b/.serena/memories/common/testing.md @@ -1,24 +1,27 @@ -# Common Module Test Setup +# Common Testing and Verification `common/` is CLJC shared code. Tests should cover the relevant runtime(s): JVM for backend/common logic and JS for frontend/exporter behavior. For geometry, component, and file-model changes, JVM tests are common and fast, but JS/browser behavior can differ when WASM modifier math or CLJS-specific state is involved. -## Running tests +## Unit tests + +Common tests live under `common/test/common_tests/` and use `clojure.test`. +They are CLJC and run on both JVM and JS. From `common/`: +- Full JVM test run: `clojure -M:dev:test` +- Full JS test run: `pnpm run test:quiet` +- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test` +- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch` +- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test` +- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute` +- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`) +- Build JS test target only: `pnpm run build:test` +- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn` +- Watch tests: `pnpm run watch:test` -```bash -pnpm run test:jvm -clojure -M:dev:test -pnpm run test:jvm --focus common-tests.logic.variants-switch-test -clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch -pnpm run test:js -pnpm run test:quiet -pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test -pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn -pnpm run watch:test -``` - -Use `test:quiet` for non-interactive JS runs; it buffers `build:test` output and forwards runner args. Common JS runner args support `--focus ` and `--log-level trace|debug|info|warn|error`. After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`. New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags compose as a union. +New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; +new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags +compose as a union. ## Test helpers @@ -45,4 +48,4 @@ For geometry-sensitive tests, read `mem:common/geometry-invariants` before posit ## Debugging -Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes. \ No newline at end of file +Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes. From 0dee6e3cb06ed37abd6511a5efa9fc0e8b34af3a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 14:32:32 +0200 Subject: [PATCH 14/14] :books: Add let binding algnment info to serena --- .serena/memories/critical-info.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index c6f57cb8bd..23881c6f89 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -14,6 +14,8 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. *After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`). +- Align `let` binding values: when a `let` form has multiple bindings spanning + several lines, align the value forms to the same column with spaces. - If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, fix them with `tools/paren-repair.bb` BEFORE running lint/format checks. See `mem:tools/paren-repair` for usage.